Cheatsheet Express.js
Framework web minimalista para Node.js
Express.js
Instalação e Setup
Instalación
mkdir mi-api && cd mi-api npm init -y npm install express npm install -D nodemon
npm init -y crea el package.json. express es la dependencia principal. nodemon (dev) reinicia el servidor automáticamente cuando los ficheros cambian.
Separar app y server
// app.js
const app = express();
app.use(express.json());
// ... rutas y middleware
module.exports = app;
// server.js
const app = require("./app");
const puerto = process.env.PORT || 3000;
app.listen(puerto, () => {
console.log(`Activo na puerto ${puerto}`);
});Separar la configuración (app.js) del arranque (server.js) facilita las pruebas — puedes importar app sin abrir puerto. Patrón recomendado para tests con supertest.
Variables de entorno
npm install dotenv
// server.js
require("dotenv").config();
const puerto = process.env.PORT || 3000;
const dbUrl = process.env.DB_URL;
// .env (NO commitear)
PORT=3000
DB_URL=mongodb://localhost/appdotenv carga variables de .env a process.env. Añade .env al .gitignore. Úsalo para puertos, URLs de BD, secretos y claves de API.
Servidor básico
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.send("Hola Express!");
});
app.listen(3000, () => {
console.log("Servidor em :3000");
});express() crea la aplicación. app.get() registra una ruta. app.listen() inicia el servidor HTTP en el puerto indicado. El callback confirma cuándo está activo.
ES Modules
// package.json
{ "type": "module" }
// app.js
import express from "express";
const app = express();
import { userRoutes } from "./routes/users.js";
app.use("/users", userRoutes);
export default app;"type": "module" activa import/export (ESM) en vez de require. Los ficheros necesitan la extensión .js en los imports. Alternativa: usar .mjs.
Configuración por entorno
const env = process.env.NODE_ENV || "development";
if (env === "production") {
app.set("trust proxy", 1);
app.use(helmet());
} else {
app.use(morgan("dev"));
}
app.listen(puerto);NODE_ENV distingue entornos. En producción: helmet, trust proxy, sin logs verbosos. En desarrollo: morgan("dev"), errores detallados. Defínelo vía NODE_ENV=production node server.js.
Parsers de body
// JSON (más común en APIs)
app.use(express.json());
// Formularios HTML
app.use(express.urlencoded({ extended: true }));
// Texto plano
app.use(express.text());
// Límite de tamaño:
app.use(express.json({ limit: "5mb" }));Sin parsers, req.body es undefined. express.json() parsea JSON. urlencoded para forms HTML. limit previene payloads enormes (DoS).
Scripts del package.json
{
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"test": "jest --watchAll"
}
}dev usa nodemon para desarrollo (auto-reload). start es para producción (Node puro). test ejecuta tests. Ejecuta con npm run dev, npm start, npm test.
Estructura recomendada
proyecto/
src/
routes/ # ajustes de rotas
controllers/ # lógica dos handlers
middleware/ # funciones intermedias
models/ # acesso a datos
services/ # reglas de negocio
app.js # configuración da app
server.js # arranque do servidor
package.jsonSeparación por responsabilidad: routes/ define URLs, controllers/ procesa peticiones, services/ contiene la lógica, models/ accede a la BD. Escalable y testeable.
express-generator
npx express-generator mi-app cd mi-app npm install npm start // Con EJS: npx express-generator --view=ejs app
express-generator crea una estructura completa con rutas, vistas y middleware preconfigurados. Incluye morgan, cookie-parser y motor de plantillas. Bueno para empezar rápido.
Rotas e Parâmetros
Métodos HTTP
app.get("/users", listar);
app.post("/users", crear);
app.put("/users/:id", substituir);
app.patch("/users/:id", actualizar);
app.delete("/users/:id", remover);
// Todos los métodos:
app.all("/rota", handler);Cada verbo HTTP tiene un método correspondiente. GET (leer), POST (crear), PUT (reemplazar), PATCH (actualización parcial), DELETE (eliminar). all acepta cualquier verbo.
Encadenamiento con route()
app.route("/libros")
.get((req, res) => {
res.json(libros);
})
.post((req, res) => {
libros.push(req.body);
res.status(201).end();
});app.route() agrupa múltiples verbos en la misma ruta. Evita repetir el camino. Útil cuando GET y POST comparten el mismo endpoint. También soporta .put(), .delete(), etc.
Rutas con sub-app
const admin = express();
admin.use(authAdmin);
admin.get("/dashboard", (req, res) => {
res.send("Painel Admin");
});
// Montar en la app principal:
app.use("/admin", admin);Una instancia express() puede montarse como sub-aplicación. Hereda su propio middleware. Útil para secciones aisladas con configuración diferente (ej: admin, API v2).
Parámetros de ruta
app.get("/users/:id", (req, res) => {
const id = req.params.id;
res.send(`User ${id}`);
});
// Múltiples parámetros:
app.get("/tiendas/:lojaId/productos/:prodId",
(req, res) => {
const { lojaId, prodId } = req.params;
});:id define un segmento dinámico en la URL. El valor queda en req.params.id. Se admiten múltiples params. Siempre strings — convierte con parseInt() o Number() si es necesario.
Parámetros opcionales y regex
// Opcional (:sufijo?)
app.get("/users/:id?", handler);
// Patrón regex
app.get("/files/:nombre(\\d+)", handler);
// Múltiples caminos:
app.get(["/a", "/b", "/c"], handler);:id? hace el parámetro opcional. (\d+) lo restringe a dígitos (regex inline). Un array de caminos registra el mismo handler en múltiples URLs. Flexibilidad total en el routing.
req.baseUrl y mount path
// app.use("/api/v1", router);
router.get("/users", (req, res) => {
req.baseUrl; // "/api/v1"
req.path; // "/users"
req.originalUrl; // "/api/v1/users"
});req.baseUrl es el prefijo donde se montó el router. req.path es el camino dentro del router. req.originalUrl es la URL completa. Útil para generar enlaces absolutos.
Query string
// GET /búsqueda?q=node&page=2&limit=10
app.get("/búsqueda", (req, res) => {
const q = req.query.q; // "node"
const page = req.query.page; // "2"
const limit = +req.query.limit || 10;
res.json({ q, page, limit });
});req.query contiene los parámetros tras ? en la URL. Los valores son strings — usa + o Number() para convertir. Ideal para filtros, paginación y ordenación.
router.param()
router.param("id", async (req, res, next, id) => {
const user = await User.findById(id);
if (!user) {
return res.status(404).json({ error: "No encontrado" });
}
req.user = user;
next();
});
router.get("/:id", (req, res) => {
res.json(req.user); // ya carregado
});router.param() ejecuta middleware cuando aparece un parámetro específico. Ideal para cargar recursos de la BD una vez y reutilizarlos en todas las rutas con :id.
Wildcard y 404
// Capturar cualquier ruta no definida:
app.use("*", (req, res) => {
res.status(404).json({
error: `Rota ${req.originalUrl} no existe`
});
});
// Debe ser el ÚLTIMO middleware* (o app.use sin camino) captura todo lo no tratado. Debe registrarse en último lugar. Devuelve 404 para rutas inexistentes. En SPAs, puede servir index.html.
Router modular
// routes/users.js
const router = express.Router();
router.get("/", listar);
router.post("/", crear);
router.get("/:id", ver);
router.put("/:id", actualizar);
module.exports = router;
// app.js
app.use("/users", require("./routes/users"));express.Router() crea un mini-app de rutas. app.use("/users", router) lo monta con prefijo. Mantiene el fichero principal limpio. Cada recurso tiene su propio fichero de rutas.
Router con middleware
const router = express.Router();
// Aplica a todas las rutas de este router:
router.use(autenticar);
router.use(logAcesso);
router.get("/", listar);
router.post("/", crear);
module.exports = router;router.use() aplica middleware solo a las rutas de ese router. No afecta otras rutas de la app. Ideal para auth específica de sección (ej: todas las rutas /admin requieren admin).
Middleware
Middleware global
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});app.use() sin camino aplica a todas las rutas. next() pasa al siguiente middleware. Sin next(), la petición queda pendiente (hang). Regístralo antes de las rutas.
next() y flujo
app.use((req, res, next) => {
req.usuario = { id: 1, nombre: "Ana" };
next();
});
// Pasar un error al handler:
app.use((req, res, next) => {
if (!req.query.token) {
return next(new Error("Token ausente"));
}
next();
});next() avanza al siguiente middleware. next(error) salta directamente al handler de error. Puedes añadir datos a req para usar en handlers posteriores.
Terceros esenciales
const morgan = require("morgan");
const cors = require("cors");
const helmet = require("helmet");
const compression = require("compression");
app.use(morgan("dev"));
app.use(cors());
app.use(helmet());
app.use(compression());morgan (logs), cors (cross-origin), helmet (headers seguros), compression (gzip). Los 4 middlewares más usados en producción. Regístralos en la parte superior de la app.
Middleware por ruta
function autenticar(req, res, next) {
if (!req.headers.authorization) {
return res.status(401).json({ error: "No autorizado" });
}
next();
}
app.get("/perfil", autenticar, handler);El middleware inline corre antes del handler final. Puedes encadenar múltiples: app.get("/", auth, validar, handler). Si no llama a next(), debe enviar una respuesta.
Middleware con configuración
function rateLimit({ max, ventana }) {
const pedidos = new Map();
return (req, res, next) => {
const ip = req.ip;
const ahora = Date.now();
// lógica de conteo...
if (excedeu) return res.status(429).end();
next();
};
}
app.use(rateLimit({ max: 100, ventana: 60000 }));Función que retorna middleware (factory pattern). Acepta opciones de configuración. Permite reutilizar con parámetros diferentes. Patrón usado por cors(), helmet(), etc.
Skip y montaje
// Morgan con skip:
app.use(morgan("dev", {
skip: (req) => req.url.startsWith("/health")
}));
// Montar en un path específico:
app.use("/uploads", express.static("uploads"));Algunos middlewares aceptan skip para ignorar ciertas peticiones. app.use(path, middleware) lo monta solo en un prefijo. Reduce overhead en rutas de health check o estáticos.
Orden de ejecución
// ¡El orden importa!
app.use(express.json()); // 1º - parse body
app.use(logger); // 2º - log
app.use("/api", rotas); // 3º - rutas
app.use(notFound); // 4º - 404
app.use(tratadorDeErros); // 5º - errores (último)El middleware corre en el orden de registro vía app.use(). Parsers antes de las rutas. 404 después de las rutas. Handler de errores siempre al final (4 argumentos). Orden incorrecto = comportamiento inesperado.
Middleware condicional
// Solo en desarrollo:
if (process.env.NODE_ENV === "development") {
app.use(morgan("dev"));
}
// Solo para /api:
app.use("/api", express.json());
// Solo para POST/PUT:
app.use((req, res, next) => {
if (["POST", "PUT"].includes(req.method)) {
return express.json()(req, res, next);
}
next();
});El middleware puede aplicarse condicionalmente por entorno, camino o método. app.use("/api", ...) lo limita a un prefijo. Evita procesamiento innecesario.
res.locals
app.use((req, res, next) => {
res.locals.anoAtual = new Date().getFullYear();
res.locals.userLogado = req.user || null;
next();
});
// En templates (EJS):
// <%= anoAtual %>
// En handlers:
app.get("/", (req, res) => {
res.render("index", { anio: res.locals.anoAtual });
});res.locals guarda datos disponibles en templates y handlers posteriores. Diferente de req — es específico de la respuesta. Ideal para datos de layout (user, año, config).
Middleware de error
// 4 argumentos obligatorios
app.use((error, req, res, next) => {
console.error(error.stack);
const status = error.status || 500;
res.status(status).json({
error: error.message,
...(process.env.NODE_ENV !== "production" && {
stack: error.stack
}),
});
});El handler de error tiene 4 parámetros (err, req, res, next). Captura errores pasados vía next(error) o lanzados. Nunca expongas el stack en producción. Debe ser el último middleware.
Async middleware
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get("/users", asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
}));Express 4 no captura errores de async automáticamente. asyncHandler envuelve la Promise y reenvía los rechazos a next(). Express 5 los captura nativamente.
Request e Input
req.body
// POST con JSON
app.post("/users", (req, res) => {
const { nombre, email } = req.body;
res.json({ nombre, email });
});
// Requiere: app.use(express.json())req.body contiene el cuerpo parseado de la petición. Solo disponible tras express.json() o urlencoded(). En GET suele ser undefined o {}.
Cookies
const cookieParser = require("cookie-parser");
app.use(cookieParser());
app.get("/", (req, res) => {
// Leer:
const sesión = req.cookies.sesión;
const assinado = req.signedCookies.token;
// Definir:
res.cookie("tema", "escuro", {
maxAge: 86400000, httpOnly: true
});
});cookie-parser parsea las cookies. req.cookies para las normales, req.signedCookies para las firmadas. res.cookie() las define con opciones: maxAge, httpOnly, secure.
req.route y matched
app.get("/users/:id", (req, res) => {
req.route.path; // "/users/:id"
req.route.methods; // { get: true }
req.baseUrl; // prefijo do router
});req.route muestra la ruta que hizo match. path es el patrón (con :id), methods los verbos registrados. Útil para debugging y logging de rutas.
req.params
// GET /users/42/posts/7
app.get("/users/:userId/posts/:postId", (req, res) => {
req.params.userId; // "42"
req.params.postId; // "7"
// Desestructuración:
const { userId, postId } = req.params;
});req.params contiene los segmentos dinámicos de la ruta. Los valores son siempre strings. Defínelos con :nombre en la ruta. Se admiten múltiples params en una misma ruta.
Propiedades del request
req.method // "GET" req.url // "/users?page=2" req.path // "/users" req.originalUrl // "/api/users?page=2" req.ip // "127.0.0.1" req.protocol // "http" ou "https" req.secure // true se HTTPS req.xhr // true se AJAX
Propiedades útiles: method (verbo), path (sin query), ip (cliente), protocol, secure. req.xhr detecta peticiones XMLHttpRequest.
Body con límite y tipos
// Limitar tamaño:
app.use(express.json({ limit: "1mb" }));
// Solo para ciertas rutas:
app.use("/api", express.json());
// Raw body (webhooks):
app.use("/webhook", express.raw({ type: "*/*" }));limit previene payloads enormes (ataque DoS). Puedes aplicar parsers solo a ciertos caminos. express.raw() mantiene el body como Buffer — necesario para verificar firmas de webhooks.
req.query
// GET /productos?categoria=libros&ordem=precio&page=2
app.get("/productos", (req, res) => {
const { categoria, ordem } = req.query;
const page = parseInt(req.query.page) || 1;
// Arrays: ?tags=a&tags=b
// req.query.tags = ["a", "b"]
});req.query son los parámetros tras ?. Los valores son strings (o arrays si se repiten). Ideal para filtros, ordenación y paginación. Siempre valida y sanitiza.
req.is() y content-type
app.post("/upload", (req, res) => {
if (req.is("json")) {
// procesar JSON
} else if (req.is("multipart/form-data")) {
// procesar upload
} else {
res.status(415).json({ error: "Tipo no soportado" });
}
});req.is(tipo) verifica el Content-Type de la petición. Retorna el tipo si coincide, false en caso contrario. 415 es el status para tipo de contenido no soportado.
Sanitizar input
const { body } = require("express-validator");
app.post("/users",
body("nombre").trim().escape(),
body("email").normalizeEmail(),
(req, res) => {
const nombre = req.body.nombre; // ya limpo
}
);trim() elimina espacios. escape() convierte entidades HTML (previene XSS). normalizeEmail() estandariza emails. Siempre sanitiza antes de guardar en la BD o renderizar.
req.headers
app.get("/info", (req, res) => {
const auth = req.headers["authorization"];
const tipo = req.get("Content-Type");
const accept = req.accepts("json");
const host = req.hostname;
const ua = req.get("User-Agent");
res.json({ auth, tipo, host, ua });
});req.headers da acceso a todos los headers (lowercase). req.get() es atajo para un header específico. req.accepts() verifica el header Accept. Los headers son case-insensitive.
req.files (multer)
const upload = multer({ dest: "uploads/" });
app.post("/fotos", upload.array("fotos", 5), (req, res) => {
req.files.forEach(f => {
console.log(f.originalname, f.size, f.mimetype);
});
res.json({ total: req.files.length });
});Con multer, req.files contiene los ficheros enviados. Cada fichero tiene originalname, size, mimetype, path. upload.array() acepta múltiples.
Response e Output
res.send()
res.send("texto simples");
res.send({ ok: true });
res.send("<h1>HTML</h1>");
res.send(Buffer.from("binario"));res.send() envía la respuesta y define el Content-Type automáticamente. String → text/html. Objeto → application/json. Buffer → application/octet-stream. Cierra el ciclo request-response.
Headers de respuesta
res.set("X-Token", "abc123");
res.set("Cache-Control", "no-cache");
res.type("application/pdf");
// Múltiples a la vez:
res.set({
"X-API-Version": "2.0",
"X-RateLimit-Remaining": "99",
});res.set() define headers en la respuesta. res.type() es atajo para Content-Type. Los headers deben definirse antes de send()/json(). Útiles para tokens, rate limit info, versionado.
res.cookie()
res.cookie("sesión", token, {
httpOnly: true,
secure: true,
maxAge: 7 * 24 * 60 * 60 * 1000,
sameSite: "strict",
});
// Eliminar:
res.clearCookie("sesión");res.cookie() define cookies con opciones. httpOnly impide el acceso vía JS (anti-XSS). secure solo en HTTPS. sameSite previene CSRF. clearCookie() la elimina.
res.json()
res.json({ users: [], total: 0 });
res.status(201).json(novoUser);
res.status(404).json({ error: "No encontrado" });
// JSONP (legacy):
res.jsonp({ datos: [] });res.json() serializa a JSON y define Content-Type: application/json. Más explícito que send() para APIs. Acepta objetos, arrays, null. jsonp añade callback (evitar).
res.download() y sendFile()
const path = require("path");
// Download (header Content-Disposition):
res.download("./relatorios/2024.pdf", "informe.pdf");
// Enviar fichero inline:
res.sendFile(path.join(__dirname, "public", "index.html"));res.download() fuerza la descarga con un nombre personalizado. res.sendFile() sirve el fichero inline (el browser lo muestra). Usa path.join() para caminos seguros. Previene path traversal.
res.location() y links
// Header Location (sin redirect):
res.location("/users/42");
res.status(201).json({ id: 42 });
// Header Link (paginación):
res.links({
next: "/users?page=3",
last: "/users?page=10",
});res.location() define el header Location sin hacer redirect. res.links() define el header Link (paginación REST). Estándar en APIs: 201 + Location para recurso creado.
Códigos de status
res.status(200).json({ ok: true }); // éxito
res.status(201).json(nuevo); // creado
res.status(204).end(); // sem contenido
res.status(400).json({ error }); // inválido
res.status(401).json({ error }); // no autenticado
res.status(403).json({ error }); // sem permiso
res.status(404).json({ error }); // no encontrado
res.status(500).json({ error }); // error servidorres.status() define el código HTTP (encadenable). 2xx éxito, 4xx error del cliente, 5xx error del servidor. 204 no tiene body — usa .end().
res.render() (templates)
// Configurar engine:
app.set("view engine", "ejs");
app.set("views", "./src/views");
// Renderizar:
app.get("/", (req, res) => {
res.render("index", {
título: "Inicio",
users: listaUsers,
});
});res.render() compila un template con datos y envía HTML. Soporta EJS, Pug, Handlebars. view engine define el motor. views define la carpeta. Los datos quedan disponibles en el template.
res.append() y attachment()
// Añadir header sin sobrescribir:
res.append("Set-Cookie", "a=1");
res.append("Set-Cookie", "b=2");
// Forzar descarga con nombre:
res.attachment("foto.png");
res.sendFile("./uploads/foto.png");res.append() añade valores a un header existente (no lo sustituye). res.attachment() define Content-Disposition para descarga. Útil para múltiples cookies o forzar descarga.
res.redirect()
res.redirect("/login");
res.redirect(301, "/nueva-url"); // permanente
res.redirect(302, "/temporario"); // temporal
res.redirect("back"); // volver (Referer)
// Tras crear un recurso:
res.redirect(`/users/${novoUser.id}`);res.redirect() envía HTTP 302 por defecto. 301 para redirect permanente (SEO). "back" usa el header Referer. Común tras POST para evitar reenvío (patrón PRG).
res.end() y streaming
// Sin cuerpo:
res.status(204).end();
// Streaming (datos grandes):
app.get("/export", (req, res) => {
res.set("Content-Type", "text/csv");
const stream = fs.createReadStream("datos.csv");
stream.pipe(res);
});res.end() cierra sin body. stream.pipe(res) envía datos en chunks (eficiente para ficheros grandes). No carga todo en memoria. Ideal para exports, vídeos, descargas grandes.
API REST e CRUD
CRUD completo
let users = [];
app.get("/users", (req, res) => res.json(users));
app.post("/users", (req, res) => {
const u = { id: Date.now(), ...req.body };
users.push(u);
res.status(201).json(u);
});
app.put("/users/:id", (req, res) => {
const i = users.findIndex(u => u.id == req.params.id);
if (i === -1) return res.status(404).end();
users[i] = { ...users[i], ...req.body };
res.json(users[i]);
});
app.delete("/users/:id", (req, res) => {
users = users.filter(u => u.id != req.params.id);
res.status(204).end();
});CRUD REST: GET (listar), POST (crear, 201), PUT (reemplazar), DELETE (eliminar, 204). 404 si el recurso no existe. Estándar para cualquier API.
Versionado de API
// Por URL (más común):
app.use("/api/v1", rotasV1);
app.use("/api/v2", rotasV2);
// Por header:
app.use((req, res, next) => {
const versión = req.get("API-Version") || "1";
req.apiVersion = versión;
next();
});El versionado por URL (/api/v1) es el más simple y explícito. Por header es más limpio pero menos visible. Mantén backward compatibility. Documenta breaking changes.
Idempotency key
const processados = new Map();
app.post("/pagamentos", (req, res) => {
const key = req.get("Idempotency-Key");
if (processados.has(key)) {
return res.json(processados.get(key));
}
const resultado = processPayment(req.body);
processados.set(key, resultado);
res.status(201).json(resultado);
});Idempotency-Key evita el procesamiento duplicado. Si la clave ya se vio, retorna la respuesta anterior. Esencial para pagos y operaciones no idempotentes. El cliente genera la clave (UUID).
Paginación
app.get("/users", (req, res) => {
const page = Math.max(1, +req.query.page || 1);
const limite = Math.min(100, +req.query.limite || 10);
const inicio = (page - 1) * limite;
const ítems = users.slice(inicio, inicio + limite);
res.json({
datos: ítems,
meta: { total: users.length, page, limite },
});
});Paginación con page y limite vía query params. Math.max/Math.min previenen valores inválidos. Retorna meta con el total para que el cliente calcule las páginas.
HATEOAS y links
app.get("/users/:id", (req, res) => {
const user = users.find(u => u.id == req.params.id);
res.json({
...user,
_links: {
self: `/users/${user.id}`,
posts: `/users/${user.id}/posts`,
delete: `/users/${user.id}`,
},
});
});HATEOAS incluye links en la respuesta para descubrimiento de recursos. _links indica las acciones disponibles. Hace la API autodescriptiva. Estándar en APIs REST maduras.
API con controller
// controllers/userController.js
exports.listar = async (req, res, next) => {
try {
const users = await User.find();
res.json(users);
} catch (e) { next(e); }
};
exports.crear = async (req, res, next) => {
try {
const user = await User.create(req.body);
res.status(201).json(user);
} catch (e) { next(e); }
};Los controllers separan la lógica de los handlers. exports.método para cada acción. try/catch con next(e) reenvía los errores. Mantiene las rutas limpias y el código testeable.
Filtros y ordenación
app.get("/productos", (req, res) => {
let resultado = [...productos];
if (req.query.categoria) {
resultado = resultado.filter(
p => p.categoria === req.query.categoria
);
}
const ordem = req.query.ordem === "desc" ? -1 : 1;
resultado.sort((a, b) => (a.precio - b.precio) * ordem);
res.json(resultado);
});Filtros vía query params (?categoria=x). Ordenación con ?ordem=desc. Combina con paginación para APIs completas. En una BD real, usa WHERE y ORDER BY.
PATCH parcial
app.patch("/users/:id", (req, res) => {
const user = users.find(u => u.id == req.params.id);
if (!user) return res.status(404).end();
// Actualizar solo los campos enviados:
const camposPermitidos = ["nombre", "email", "activo"];
camposPermitidos.forEach(campo => {
if (req.body[campo] !== undefined) {
user[campo] = req.body[campo];
}
});
res.json(user);
});PATCH actualiza parcialmente (solo los campos enviados). PUT reemplaza todo. Una whitelist de campos evita actualizar id o role. Siempre valida los campos recibidos.
Service layer
// services/userService.js
class UserService {
async listar(filtros) {
return User.find(filtros).lean();
}
async crear(datos) {
if (await this.emailExiste(datos.email)) {
throw new ApiError(409, "Email ya existe");
}
return User.create(datos);
}
}
module.exports = new UserService();Los services contienen las reglas de negocio (validaciones, lógica). Los controllers solo orquestan (recibir, llamar al service, responder). Separación clara: ruta → controller → service → model. Máxima testabilidad con un exports por clase.
Respuestas estandarizadas
// Éxito:
res.json({
éxito: true,
datos: users,
meta: { total: 50 },
});
// Error:
res.status(400).json({
éxito: false,
error: "Email ya existe",
campos: { email: "Deve ser único" },
});Estructura consistente: éxito (boolean), datos (payload), error (mensaje), campos (errores por campo). Facilita el parsing en el frontend. Documenta el formato.
Bulk operations
app.post("/users/bulk", (req, res) => {
const nuevos = req.body.usuarios;
if (!Array.isArray(nuevos)) {
return res.status(400).json({ error: "Array esperado" });
}
const creados = nuevos.map(u => ({ id: Date.now(), ...u }));
users.push(...creados);
res.status(201).json({ creados: creados.length });
});Operaciones en masa para crear/actualizar/eliminar múltiples recursos vía POST. Valida que sea un array. Limita el tamaño (ej: max 100). Retorna un resumen de la operación. Útil para imports y syncs.
Recursos Avançados
Upload con multer
const multer = require("multer");
const storage = multer.diskStorage({
destination: "./uploads/",
filename: (req, file, cb) => {
cb(null, `${Date.now()}-${file.originalname}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
cb(null, file.mimetype.startsWith("image/"));
},
});
app.post("/upload", upload.single("foto"), (req, res) => {
res.json({ path: req.file.path });
});multer procesa multipart/form-data. diskStorage controla el destino y el nombre. limits restringe el tamaño. fileFilter valida el tipo. single(), array(), fields() para diferentes usos.
Agendamiento (node-cron)
const cron = require("node-cron");
// Todos los días a las 3h:
cron.schedule("0 3 * * *", async () => {
console.log("Limpiar tokens expirados...");
await Token.deleteMany({ expira: { $lt: new Date() } });
});
// Cada 5 minutos:
cron.schedule("*/5 * * * *", () => {
verificarServicos();
});node-cron agenda tareas recurrentes en el proceso Express. Sintaxis cron estándar (min hora día mes semana). Ideal para limpieza, informes, syncs. Para producción pesada, usa Bull/BullMQ.
Cluster mode
const cluster = require("cluster");
const os = require("os");
if (cluster.isPrimary) {
const cpus = os.cpus().length;
for (let i = 0; i < cpus; i++) {
cluster.fork();
}
cluster.on("exit", () => cluster.fork());
} else {
require("./server");
}cluster crea un proceso por CPU. El primary distribuye las conexiones. Si un worker muere, se crea otro. Aprovecha multi-core. Alternativa moderna: usar PM2 con -i max.
WebSockets (Socket.IO)
const http = require("http");
const { Server } = require("socket.io");
const server = http.createServer(app);
const io = new Server(server);
io.on("connection", (socket) => {
socket.on("mensaje", (datos) => {
io.emit("mensaje", datos);
});
socket.on("disconnect", () => {});
});
server.listen(3000);Socket.IO añade WebSockets a Express. io.on("connection") cuando un cliente conecta. socket.emit() envía a uno. io.emit() a todos. Ideal para chat, notificaciones en tiempo real.
Server-Sent Events
app.get("/eventos", (req, res) => {
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const id = setInterval(() => {
res.write(`data: ${JSON.stringify({ hora: new Date() })}\n\n`);
}, 1000);
req.on("close", () => clearInterval(id));
});SSE envía eventos del servidor al cliente (unidireccional). Header text/event-stream. res.write() envía sin cerrar. Más simple que WebSockets para notificaciones y feeds.
Health check
app.get("/health", async (req, res) => {
const checks = {
uptime: process.uptime(),
bd: "ok",
redis: "ok",
};
try {
await mongoose.connection.db.admin().ping();
} catch {
checks.bd = "error";
return res.status(503).json(checks);
}
res.json(checks);
});/health comprueba si el servicio está operativo. Prueba las conexiones (BD, Redis, APIs). Retorna 503 si algo falla. Usado por load balancers y Kubernetes para liveness/readiness probes.
Templates EJS
npm install ejs
// app.js
app.set("view engine", "ejs");
// views/index.ejs
// <h1><%= título %></h1>
// <% users.forEach(u => { %>
// <p><%= u.nombre %></p>
// <% }) %>
app.get("/", (req, res) => {
res.render("index", { título: "Home", users });
});EJS es el motor de templates más simple. <%= %> imprime (escapado). <% %> ejecuta lógica. res.render() compila con datos. Incluye partials con <%- include() %>.
Proxy inverso
const { createProxyMiddleware } = require("http-proxy-middleware");
// Encaminhar /api/legacy para outro servicio:
app.use("/api/legacy", createProxyMiddleware({
target: "http://servico-antiguo:4000",
changeOrigin: true,
pathRewrite: { "^/api/legacy": "" },
}));http-proxy-middleware encamina las peticiones a otros servicios. target es el destino. pathRewrite elimina el prefijo. Útil para migraciones graduales, microservicios y APIs externas.
Caching con Redis
const Redis = require("ioredis");
const redis = new Redis();
async function cacheMiddleware(req, res, next) {
const key = `cache:${req.originalUrl}`;
const cached = await redis.get(key);
if (cached) return res.json(JSON.parse(cached));
const originalJson = res.json.bind(res);
res.json = (data) => {
redis.setex(key, 60, JSON.stringify(data));
return originalJson(data);
};
next();
}
app.get("/api/productos", cacheMiddleware, handler);Redis cachea respuestas por URL con un TTL (60s). Intercepta res.json() para guardar. Si el cache existe, responde sin ir a la BD. Reduce drásticamente la carga en endpoints leídos con frecuencia.
Graceful shutdown
const server = app.listen(3000);
process.on("SIGTERM", () => {
console.log("Encerrando...");
server.close(() => {
mongoose.connection.close();
redis.quit();
process.exit(0);
});
// Forzar tras 10s:
setTimeout(() => process.exit(1), 10000);
});SIGTERM lo envía Docker/el orquestador. server.close() deja de aceptar conexiones y espera a que las activas terminen. Cierra las conexiones a la BD/Redis. El timeout fuerza el cierre si algo se bloquea.
Boas Práticas e Ferramentas
Logging con morgan
const morgan = require("morgan");
// Desarrollo (coloreado):
app.use(morgan("dev"));
// GET /users 200 3.2ms - 1.2kb
// Producción (detallado):
app.use(morgan("combined"));
// A un fichero:
const fs = require("fs");
app.use(morgan("combined", {
stream: fs.createWriteStream("access.log", { flags: "a" })
}));morgan registra todas las peticiones HTTP. "dev" es coloreado y conciso. "combined" es formato Apache (para producción). stream redirige a un fichero. Combina con Winston para logs de app.
Docker
# Dockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . EXPOSE 3000 CMD ["node", "server.js"] # .dockerignore node_modules .env
node:20-alpine es la imagen base ligera. npm ci instala exacto (lock file). .dockerignore excluye node_modules y .env. EXPOSE documenta el puerto. Multi-stage para un build más pequeño.
Checklist de producción
// ✓ NODE_ENV=production // ✓ helmet() + cors() configurados // ✓ Rate limiting activo // ✓ Body com limit // ✓ HTTPS forzado // ✓ Logs estruturados (Winston) // ✓ Graceful shutdown // ✓ Health check endpoint // ✓ Sem secrets no código // ✓ PM2 ou Docker // ✓ Testes a passar
Antes del deploy: seguridad (helmet, cors, limits), observabilidad (logs, health), resiliencia (graceful shutdown, PM2), e higiene (sin secretos, tests). Revisa esta lista en cada release.
Winston (logs estructurados)
const winston = require("winston");
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "app.log" }),
new winston.transports.Console({ format: winston.format.simple() }),
],
});
logger.info("Servidor iniciado", { puerto: 3000 });
logger.error("Fallo BD", { error: e.message });Winston es el logger más popular. Niveles: error, warn, info, debug. Múltiples transports (consola, fichero, servicios). Formato JSON para producción (parseable por ELK, Datadog).
Organización por feature
src/
features/
users/
user.routes.js
user.controller.js
user.service.js
user.model.js
user.test.js
posts/
post.routes.js
post.controller.js
shared/
middleware/
utils/
app.jsOrganizar por feature agrupa todo lo de un recurso junto. Más escalable que separar por tipo (routes/, controllers/). Fácil de encontrar y eliminar features. shared/ para código común.
Express 5 (novedades)
npm install express@5
// Errores async capturados de forma nativa:
app.get("/users", async (req, res) => {
const users = await User.find(); // reject → 500 automático
res.json(users);
});
// El wildcard cambió:
app.get("/users/{id}", handler); // en lugar de :id
app.use("/*splat", notFound); // en lugar de *Express 5 captura los errores async sin wrapper. La sintaxis de ruta cambió: {id} en vez de :id, *splat en vez de *. Promises nativas en res.redirect(). Breaking changes mínimos.
Tests con supertest
const request = require("supertest");
const app = require("./app");
describe("API Users", () => {
it("GET /users retorna lista", async () => {
const res = await request(app)
.get("/users")
.expect(200)
.expect("Content-Type", /json/);
expect(res.body).toBeInstanceOf(Array);
});
it("POST /users cria", async () => {
const res = await request(app)
.post("/users")
.send({ nombre: "Ana", email: "ana@test.com" })
.expect(201);
});
});supertest prueba endpoints sin abrir un puerto. request(app) simula peticiones HTTP. .expect() verifica el status y los headers. .send() envía el body. Combina con Jest o Mocha. Prueba la app entera (integración).
Dependency injection
// El controller recibe el service (testeable):
function createUserController(userService) {
return {
async listar(req, res) {
const users = await userService.listar();
res.json(users);
},
async crear(req, res) {
const user = await userService.crear(req.body);
res.status(201).json(user);
},
};
}
// Inyección:
const ctrl = createUserController(new UserService());Las factory functions reciben las dependencias como argumentos vía require. Facilita los tests con mocks. Sin acoplamiento a implementaciones concretas. Alternativa: usar un contenedor DI (tsyringe, awilix).
PM2 (process manager)
npm install -g pm2 pm2 start server.js -i max pm2 list pm2 logs pm2 restart all pm2 save pm2 startup
PM2 gestiona los procesos Node en producción. -i max usa todos los CPUs (cluster). Auto-restart en crash. pm2 logs muestra el output. pm2 startup inicia en el boot. Monitoriza con pm2 monit.
Config centralizada
// config/index.js
require("dotenv").config();
module.exports = {
puerto: process.env.PORT || 3000,
dbUrl: process.env.DB_URL,
jwtSecret: process.env.JWT_SECRET,
jwtExpiry: "24h",
cors: {
origin: process.env.CORS_ORIGIN || "*",
},
isProduction: process.env.NODE_ENV === "production",
};Centraliza toda la configuración en un módulo. Un único punto para las variables de entorno. Defaults sensatos para desarrollo. Valida que los secretos existen al arrancar. Nunca esparzas process.env por el código.
Validação e Erros
Validación manual
function validarUser(req, res, next) {
const { nombre, email } = req.body;
const errores = [];
if (!nombre || nombre.trim().length < 2)
errores.push("Nombre debe ter 2+ caracteres");
if (!email || !email.includes("@"))
errores.push("Email inválido");
if (errores.length)
return res.status(400).json({ errores });
next();
}
app.post("/users", validarUser, crear);Un middleware de validación comprueba los campos antes del handler. Retorna 400 con una lista de errores si es inválido. next() solo si todo pasa. Simple pero repetitivo para muchos campos.
Clase de error personalizada
class ApiError extends Error {
constructor(status, mensaje, campos = {}) {
super(mensaje);
this.status = status;
this.campos = campos;
this.isOperacional = true;
}
}
// Uso:
throw new ApiError(404, "User no encontrado");
throw new ApiError(422, "Error de validación", {
email: "Ya existe"
});ApiError estandariza los errores con un status y campos. isOperacional distingue los errores esperados de los bugs. El handler global formatea la respuesta. Evita repetir res.status().json() por todas partes.
notFound middleware
// Después de todas las rutas:
app.use((req, res) => {
res.status(404).json({
éxito: false,
error: `Rota ${req.method} ${req.originalUrl} no existe`,
});
});
// Para una SPA (servir index.html):
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "public", "index.html"));
});El middleware 404 captura las rutas no definidas. Para APIs, retorna JSON. Para SPAs, sirve index.html (routing en el cliente). Debe ir después de todas las rutas pero antes del handler de error.
express-validator
const { body, validationResult } = require("express-validator");
app.post("/users",
body("nombre").notEmpty().isLength({ min: 2 }),
body("email").isEmail().normalizeEmail(),
body("edad").optional().isInt({ min: 18 }),
(req, res) => {
const errores = validationResult(req);
if (!errores.isEmpty())
return res.status(400).json({ errores: errores.array() });
// continuar...
}
);express-validator valida de forma declarativa. body(), param(), query() para cada fuente. validationResult() recoge los errores. Soporta optional(), sanitización y mensajes personalizados.
Handler de error global
app.use((error, req, res, next) => {
const status = error.status || 500;
const respuesta = {
éxito: false,
error: error.message,
...(error.campos && { campos: error.campos }),
};
if (process.env.NODE_ENV !== "production") {
respuesta.stack = error.stack;
}
res.status(status).json(respuesta);
});Un único handler formatea todos los errores. Los errores operacionales muestran el mensaje. Los bugs (500) ocultan detalles en producción. stack solo en desarrollo. Siempre el último middleware.
try/catch en controllers
exports.crear = async (req, res, next) => {
try {
const user = await UserService.crear(req.body);
res.status(201).json(user);
} catch (error) {
if (error.code === 11000) {
return res.status(409).json({
error: "Email ya registado"
});
}
next(error);
}
};try/catch permite tratar errores específicos (ej: duplicado 11000) y reenviar el resto con next(error). Más control que asyncHandler puro. Combina ambos para máxima cobertura.
Validación con Zod
const { z } = require("zod");
const userSchema = z.object({
nombre: z.string().min(2),
email: z.string().email(),
edad: z.number().min(18).optional(),
});
app.post("/users", (req, res) => {
const resultado = userSchema.safeParse(req.body);
if (!resultado.success) {
return res.status(400).json({
errores: resultado.error.issues
});
}
const datos = resultado.data; // tipado
});Zod valida y hace parse con tipos. safeParse() no lanza excepción. resultado.data es el input validado y transformado. Más moderno que express-validator. Soporta schemas complejos.
Errores async (Express 4)
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Uso:
app.get("/users/:id", asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new ApiError(404, "No encontrado");
res.json(user);
}));Express 4 no captura los rejects de async. asyncHandler envuelve y reenvía a next(). En Express 5, los errores async se capturan de forma nativa — sin wrapper necesario.
Validación de params y query
const { param, query } = require("express-validator");
app.get("/users/:id",
param("id").isMongoId(),
query("page").optional().isInt({ min: 1 }),
handler
);
// Con Zod:
const paramsSchema = z.object({ id: z.string().uuid() });
const querySchema = z.object({ page: z.coerce.number().default(1) });Valida también params y query — no solo el body. param("id").isMongoId() previene queries inválidas en la BD. z.coerce.number() convierte un string a numero automáticamente.
Errores de validación de Mongoose
app.use((error, req, res, next) => {
if (error.name === "ValidationError") {
const campos = {};
for (const [k, v] of Object.entries(error.errors)) {
campos[k] = v.message;
}
return res.status(422).json({ campos });
}
if (error.name === "CastError") {
return res.status(400).json({ error: "ID inválido" });
}
next(error);
});Mongoose lanza ValidationError y CastError. Formatéalos en 422 con campos específicos. CastError ocurre con ObjectIds inválidos. Traduce los errores de la BD a respuestas amigables.
Segurança e Performance
CORS
const cors = require("cors");
// Liberar todo (dev):
app.use(cors());
// Configuración específica:
app.use(cors({
origin: "http://localhost:5173",
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
}));cors permite peticiones de otros dominios. Sin esto, el browser las bloquea (Same-Origin Policy). origin autoriza dominios específicos. credentials: true permite cookies/headers de auth.
Autenticación JWT
const jwt = require("jsonwebtoken");
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "Token ausente" });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Token inválido" });
}
}jsonwebtoken verifica tokens JWT. El header Authorization: Bearer token es el estándar. jwt.verify() valida la firma y la expiración. req.user queda disponible para los handlers siguientes.
Trust proxy y HTTPS
// Detrás de Nginx/load balancer:
app.set("trust proxy", 1);
// Forzar HTTPS:
app.use((req, res, next) => {
if (req.headers["x-forwarded-proto"] !== "https") {
return res.redirect(301, `https://${req.hostname}${req.url}`);
}
next();
});trust proxy hace que Express lea los headers X-Forwarded-* (IP real, protocolo). Sin esto, req.ip es el proxy. Fuerza HTTPS en producción con un redirect 301. Esencial detrás de load balancers.
Helmet
const helmet = require("helmet");
app.use(helmet());
// O configurar individualmente:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "cdn.ejemplo.com"],
},
},
crossOriginEmbedderPolicy: false,
}));helmet() define headers de seguridad: CSP, X-Frame-Options, HSTS, etc. Previene XSS, clickjacking, MIME sniffing. Indispensable en producción. Zero config ya protege bastante.
Hash de passwords
const bcrypt = require("bcrypt");
// Registro:
const hash = await bcrypt.hash(password, 12);
await User.create({ email, password: hash });
// Login:
const user = await User.findOne({ email });
const válido = await bcrypt.compare(password, user.password);
if (!válido) return res.status(401).end();bcrypt hace hash con salt automático. hash(contrasena, 12) — 12 rounds (más seguro, más lento). compare() verifica sin exponer la contraseña. Nunca guardes passwords en texto plano.
DDoS y payload limits
// Limitar el body:
app.use(express.json({ limit: "100kb" }));
// Limitar por IP (agresivo para login):
const loginLimit = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { error: "Muitas tentativas" },
});
app.use("/login", loginLimit);
// Timeout:
app.use((req, res, next) => {
req.setTimeout(30000);
next();
});Combina un limit en el body parser + rate limiting por endpoint. Login con un límite agresivo (5/min). Un timeout previene las conexiones colgadas. Capas de defensa contra el abuso.
Rate limiting
const rateLimit = require("express-rate-limit");
const limitador = rateLimit({
windowMs: 15 * 60 * 1000, // 15 min
max: 100,
message: { error: "Muitos pedidos, tente depois" },
standardHeaders: true,
});
app.use("/api/", limitador);express-rate-limit limita las peticiones por IP/ventana. max: 100 = 100 peticiones en 15 min. Retorna 429 cuando se excede. standardHeaders envía headers RateLimit-*. Previene brute force.
Ficheros estáticos seguros
app.use(express.static("public", {
maxAge: "1d",
etag: true,
index: false,
dotfiles: "ignore",
}));
// Cache inmutable para assets con hash:
app.use("/assets", express.static("dist", {
maxAge: "1y",
immutable: true,
}));express.static() sirve ficheros. maxAge define el cache. index: false previene el listado. dotfiles: "ignore" oculta .env. immutable para assets con fingerprint en el nombre.
Compression
const compression = require("compression");
app.use(compression());
// Con filtro:
app.use(compression({
filter: (req, res) => {
if (req.headers["x-no-compress"]) return false;
return compression.filter(req, res);
},
level: 6,
}));compression hace gzip/brotli de las respuestas. Reduce el tamaño en un 60-80%. level (1-9) controla compresión vs CPU. Aplícalo globalmente. En producción con Nginx, puede ser redundante.
Prevenir injection
// NUNCA:
db.query(`SELECT * FROM users WHERE id = ${req.params.id}`);
// SIEMPRE (parameterized):
db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
// Mongoose (seguro por defecto):
User.findById(req.params.id);
// Escapar output HTML:
const escapeHtml = require("escape-html");
res.send(`<p>${escapeHtml(nombre)}</p>`);Usa siempre queries parametrizadas ($1, ?) — nunca interpolación. Mongoose/ORMs protegen por defecto. escape-html previene XSS en el output. Valida los tipos de los params.