Skip to content

Express ​

Express es el framework web más usado de Node. Simplifica rutas, lectura del body, middleware y respuestas. En esta lección aprenderás sus bases y una estructura de proyecto profesional con rutas, controladores y middleware separados. Usaremos Express 5, la versión actual.

Concepto ​

Express es una capa delgada sobre http. Su idea central es la cadena de middleware: cada petición pasa por una serie de funciones, como una línea de producción. Cada estación puede revisar, modificar, responder o pasar la petición a la siguiente con next().

txt
Petición → [logger] → [express.json()] → [autenticación] → [ruta] → Respuesta
                                              │
                                              └─ si falla → responde 401

Novedades de Express 5 que importan:

  • Si una ruta async lanza un error o una promesa se rechaza, Express lo manda solo al manejador de errores (ya no necesitas try/catch en cada ruta).
  • Sintaxis de rutas más estricta: el comodín ahora se escribe /*ruta en lugar de *.
  • Requiere Node 18+.

Instalación ​

bash
mkdir api-express && cd api-express
npm init -y
npm pkg set type=module
npm i express

Primer servidor ​

js
// src/index.js
import express from 'express'

const app = express()
const PUERTO = process.env.PORT ?? 3000

// Middleware que convierte el body JSON en req.body
app.use(express.json())

app.get('/', (req, res) => {
  res.send('¡Hola desde Express!')
})

app.get('/api/saludo', (req, res) => {
  res.json({ mensaje: 'Hola', fecha: new Date() })
})

app.listen(PUERTO, () => {
  console.log(`Servidor en http://localhost:${PUERTO}`)
})

Compara con la lección anterior: res.json() pone el header, el status y convierte a JSON por ti.

Rutas ​

js
// Parámetros de ruta: /usuarios/42
app.get('/usuarios/:id', (req, res) => {
  res.json({ id: req.params.id }) // ¡siempre llega como string!
})

// Varios parámetros
app.get('/tiendas/:tiendaId/productos/:productoId', (req, res) => {
  const { tiendaId, productoId } = req.params
  res.json({ tiendaId, productoId })
})

// Query string: /buscar?q=laptop&pagina=2
app.get('/buscar', (req, res) => {
  const { q = '', pagina = '1' } = req.query
  res.json({ q, pagina: Number(pagina) })
})

// Body (POST/PUT/PATCH) gracias a express.json()
app.post('/contacto', (req, res) => {
  const { nombre, email } = req.body
  res.status(201).json({ recibido: true, nombre, email })
})

// Métodos de respuesta comunes
app.get('/ejemplos', (req, res) => {
  // res.send('texto o html')
  // res.json({ ... })
  // res.status(404).json({ error: '...' })
  // res.sendStatus(204)
  // res.redirect('/otra-ruta')
  // res.sendFile(rutaAbsoluta)
  // res.download(rutaAbsoluta)
  res.json({ ok: true })
})

Agrupar rutas con Router ​

js
// src/routes/productos.routes.js
import { Router } from 'express'

const router = Router()

router.get('/', (req, res) => res.json([]))          // GET /api/productos
router.get('/:id', (req, res) => res.json({ id: req.params.id }))
router.post('/', (req, res) => res.status(201).json(req.body))

export default router
js
// en src/index.js
import productosRouter from './routes/productos.routes.js'
app.use('/api/productos', productosRouter)

Middleware ​

Un middleware es una función (req, res, next):

js
// Middleware propio: registra cada petición y cuánto tardó
function logger(req, res, next) {
  const inicio = Date.now()
  res.on('finish', () => {
    console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - inicio} ms)`)
  })
  next() // ¡pasa a la siguiente función!
}

app.use(logger) // global: aplica a todas las rutas

// Middleware sólo para ciertas rutas
function soloAdmin(req, res, next) {
  if (req.headers['x-rol'] !== 'admin') {
    return res.status(403).json({ error: 'Acceso denegado' })
  }
  next()
}

app.delete('/api/usuarios/:id', soloAdmin, (req, res) => {
  res.sendStatus(204)
})

Middleware integrados y populares:

js
app.use(express.json({ limit: '1mb' }))              // body JSON
app.use(express.urlencoded({ extended: true }))      // formularios HTML
app.use(express.static('public'))                    // archivos estáticos
// De terceros (los veremos más adelante): cors, helmet, morgan, cookie-parser

Manejo de errores y 404 ​

js
// 404: va DESPUÉS de todas las rutas
app.use((req, res) => {
  res.status(404).json({ error: `No existe ${req.method} ${req.originalUrl}` })
})

// Manejador de errores: 4 parámetros, va al FINAL
app.use((err, req, res, next) => {
  console.error(err)
  res.status(err.status ?? 500).json({ error: err.message ?? 'Error interno' })
})
js
// Con Express 5, un error en una ruta async llega solo al manejador
app.get('/falla', async (req, res) => {
  throw new Error('Algo se rompió') // → manejador de errores, status 500
})

Estructura de proyecto profesional ​

Separar responsabilidades hace el código fácil de mantener y probar:

txt
api-express/
├── src/
│   ├── app.js                  ← configura Express (sin listen)
│   ├── index.js                ← arranca el servidor
│   ├── routes/                 ← qué URL llama a qué controlador
│   │   └── tareas.routes.js
│   ├── controllers/            ← reciben req, responden res
│   │   └── tareas.controller.js
│   ├── services/               ← lógica de negocio y acceso a datos
│   │   └── tareas.service.js
│   └── middlewares/
│       ├── logger.js
│       └── errores.js
├── package.json
└── .gitignore
  • Rutas: sólo definen URL + método + middlewares + controlador.
  • Controladores: traducen HTTP ↔ lógica (leen req, llaman al servicio, responden).
  • Servicios: la lógica pura. No saben nada de req ni res; así puedes reutilizarlos y probarlos.
js
// Lógica de negocio. Hoy en memoria; mañana una base de datos.
let tareas = [
  { id: 1, titulo: 'Aprender Express', hecha: false }
]
let siguienteId = 2

export function listar({ hecha } = {}) {
  if (hecha === undefined) return tareas
  return tareas.filter((t) => t.hecha === hecha)
}

export function obtener(id) {
  return tareas.find((t) => t.id === id) ?? null
}

export function crear(titulo) {
  const tarea = { id: siguienteId++, titulo, hecha: false }
  tareas.push(tarea)
  return tarea
}

export function actualizar(id, cambios) {
  const tarea = obtener(id)
  if (!tarea) return null
  Object.assign(tarea, cambios)
  return tarea
}

export function eliminar(id) {
  const antes = tareas.length
  tareas = tareas.filter((t) => t.id !== id)
  return tareas.length < antes
}
js
import * as servicio from '../services/tareas.service.js'

export function listar(req, res) {
  const { hecha } = req.query
  const filtro = hecha === undefined ? {} : { hecha: hecha === 'true' }
  res.json(servicio.listar(filtro))
}

export function obtener(req, res) {
  const tarea = servicio.obtener(Number(req.params.id))
  if (!tarea) return res.status(404).json({ error: 'Tarea no encontrada' })
  res.json(tarea)
}

export function crear(req, res) {
  const { titulo } = req.body ?? {}
  if (!titulo || typeof titulo !== 'string') {
    return res.status(400).json({ error: 'El título es obligatorio' })
  }
  res.status(201).json(servicio.crear(titulo.trim()))
}

export function actualizar(req, res) {
  const { titulo, hecha } = req.body ?? {}
  const cambios = {}
  if (titulo !== undefined) cambios.titulo = String(titulo)
  if (hecha !== undefined) cambios.hecha = Boolean(hecha)

  const tarea = servicio.actualizar(Number(req.params.id), cambios)
  if (!tarea) return res.status(404).json({ error: 'Tarea no encontrada' })
  res.json(tarea)
}

export function eliminar(req, res) {
  const ok = servicio.eliminar(Number(req.params.id))
  if (!ok) return res.status(404).json({ error: 'Tarea no encontrada' })
  res.sendStatus(204)
}
js
import { Router } from 'express'
import * as ctrl from '../controllers/tareas.controller.js'

const router = Router()

router.get('/', ctrl.listar)
router.get('/:id', ctrl.obtener)
router.post('/', ctrl.crear)
router.patch('/:id', ctrl.actualizar)
router.delete('/:id', ctrl.eliminar)

export default router
js
export function logger(req, res, next) {
  const inicio = Date.now()
  res.on('finish', () => {
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} - ${Date.now() - inicio}ms`)
  })
  next()
}
js
export function noEncontrado(req, res) {
  res.status(404).json({ error: `Ruta no encontrada: ${req.method} ${req.originalUrl}` })
}

// eslint-disable-next-line no-unused-vars
export function manejadorErrores(err, req, res, next) {
  console.error(err)
  const status = err.status ?? 500
  res.status(status).json({
    error: status === 500 ? 'Error interno del servidor' : err.message
  })
}
js
import express from 'express'
import { logger } from './middlewares/logger.js'
import { noEncontrado, manejadorErrores } from './middlewares/errores.js'
import tareasRouter from './routes/tareas.routes.js'

const app = express()

app.use(express.json())
app.use(logger)

app.get('/api/salud', (req, res) => res.json({ ok: true }))
app.use('/api/tareas', tareasRouter)

app.use(noEncontrado)
app.use(manejadorErrores)

export default app
js
import app from './app.js'

const PUERTO = process.env.PORT ?? 3000
app.listen(PUERTO, () => console.log(`API en http://localhost:${PUERTO}`))
json
{
  "name": "api-express",
  "type": "module",
  "scripts": {
    "dev": "node --watch src/index.js",
    "start": "node src/index.js"
  },
  "dependencies": {
    "express": "^5.1.0"
  }
}
bash
npm run dev
curl http://localhost:3000/api/tareas
curl -X POST http://localhost:3000/api/tareas -H "Content-Type: application/json" -d '{"titulo":"Hacer ejercicio"}'
curl -X PATCH http://localhost:3000/api/tareas/1 -H "Content-Type: application/json" -d '{"hecha":true}'

Buena práctica

Separa app.js (configuración) de index.js (listen). Así en los tests puedes importar app sin abrir un puerto real. Lo usaremos en la lección de testing.

Error común

El orden importa. Si registras app.use(noEncontrado) antes de tus rutas, todo responderá 404. Y express.json() debe ir antes de las rutas que leen req.body.

Error común

Olvidar return al responder dentro de un if: el código sigue y vuelve a responder, causando Cannot set headers after they are sent to the client.

Ejercicios ​

  1. Agrega un middleware validarId que responda 400 si req.params.id no es un número entero positivo, y úsalo en las rutas con :id.
  2. Agrega GET /api/tareas/estadisticas que devuelva { total, hechas, pendientes }. Cuidado con el orden respecto a /:id.
Ver solución
js
// src/middlewares/validarId.js
export function validarId(req, res, next) {
  const id = Number(req.params.id)
  if (!Number.isInteger(id) || id <= 0) {
    return res.status(400).json({ error: 'El id debe ser un entero positivo' })
  }
  next()
}
js
// src/routes/tareas.routes.js
import { Router } from 'express'
import * as ctrl from '../controllers/tareas.controller.js'
import { validarId } from '../middlewares/validarId.js'

const router = Router()

router.get('/', ctrl.listar)
// ¡Antes de /:id! Si no, "estadisticas" se tomaría como un id
router.get('/estadisticas', ctrl.estadisticas)
router.get('/:id', validarId, ctrl.obtener)
router.post('/', ctrl.crear)
router.patch('/:id', validarId, ctrl.actualizar)
router.delete('/:id', validarId, ctrl.eliminar)

export default router
js
// en tareas.controller.js
export function estadisticas(req, res) {
  const todas = servicio.listar()
  const hechas = todas.filter((t) => t.hecha).length
  res.json({ total: todas.length, hechas, pendientes: todas.length - hechas })
}

Resumen ​

  • Express organiza el servidor en rutas y middleware (req, res, next).
  • req.params, req.query y req.body (con express.json()) dan acceso a los datos.
  • Express 5 captura errores de funciones async automáticamente.
  • Estructura profesional: routes → controllers → services, con app.js separado de index.js.

Academia Web — aprende, copia y construye.