Fastify a fondo: el framework rápido de Node.js, explicado por dentro y con código probado

Router radix, JSON Schema con Ajv, serialización compilada, hooks, encapsulación de plugins, TypeScript con TypeBox, seguridad, tests y despliegue: todo Fastify 5 con ejemplos probados.

Portada: Fastify a fondo: el framework rápido de Node.js, explicado por dentro y con código probado

Si llevas un tiempo con Node.js, casi seguro que empezaste con Express. Es lo que sale en todos los tutoriales, funciona y hay respuestas para todo. Pero llega un día en que tu API crece y empiezas a echar cosas en falta: validar lo que entra sin escribir cien if, no filtrar por error la contraseña de un usuario en una respuesta, tener logs decentes, documentar la API sin mantener un fichero aparte, que el código no se convierta en un plato de espaguetis de middlewares… y, de paso, que vaya rápido.

Fastify nació exactamente para eso. Lo crearon en 2016 Matteo Collina y Tomas Della Vedova (los dos con mucho peso en el propio Node.js), hoy es un proyecto de la OpenJS Foundation y lo usan en producción empresas de todos los tamaños. Su lema es "rápido y con poca sobrecarga", pero en mi experiencia lo mejor de Fastify no es la velocidad: es que te obliga, con suavidad, a hacer las cosas bien.

Este artículo es largo a propósito. No me quiero quedar en el "hola mundo": vamos a ver cómo funciona por dentro (el router, la validación, la serialización, el ciclo de vida, la encapsulación), y después montaremos una API completa en TypeScript con validación, documentación OpenAPI, seguridad, base de datos, tests y despliegue en Docker/Coolify. Todo el código que verás lo he ejecutado de verdad en mi servidor con Fastify 5.12.5, Node.js 20.20 y 24.21, y TypeScript 7.0.2; las salidas que enseño son las reales.

📦
Versiones probadas (29-sep-2026): fastify 5.12.5 · @fastify/cors 11.3.0 · @fastify/helmet 13.1.1 · @fastify/rate-limit 11.2.0 · @fastify/jwt 10.2.2 · @fastify/swagger 9.9.0 · @fastify/swagger-ui 6.1.1 · @fastify/postgres 6.1.0 · @fastify/type-provider-typebox 6.1.0 · typebox 1.3.34 · Node.js 20.20.2 y 24.21.0.

Qué es Fastify (y qué no es)

Fastify es un framework web para Node.js: recibe peticiones HTTP, decide qué función las atiende y te ayuda a construir la respuesta. Está en la misma liga que Express, Koa, Hono o el motor HTTP de NestJS (de hecho, NestJS puede usar Fastify por debajo en lugar de Express). No es un framework "con baterías" como NestJS o AdonisJS: no te impone controladores, inyección de dependencias ni ORM. Pero tampoco es tan minimalista como Express: trae de serie validación, serialización, logging, un sistema de plugins serio y un modo de probar la aplicación sin abrir puertos.

Sus principios de diseño, tal y como los define el propio proyecto, son:

  • Rendimiento: poca sobrecarga por petición. Es de los frameworks más rápidos de Node.js.
  • Extensible: todo se construye con hooks, plugins y decoradores.
  • Basado en schemas: JSON Schema para validar lo que entra y para serializar lo que sale.
  • Logging: los logs son críticos, pero caros; por eso usa pino, el logger más rápido del ecosistema.
  • Amigable para el desarrollador: expresivo sin sacrificar rendimiento ni seguridad.
  • TypeScript: los tipos vienen incluidos en el paquete y se mantienen al día.

Sus características, de un vistazo

CaracterísticaQué te daPieza por dentro
Router muy rápidoRutas con parámetros, comodines, regex, opcionales, restricciones por versión o hostfind-my-way (árbol radix)
Validación con JSON Schemabody, query, params y headers validados antes del handler, con valores por defecto y conversión de tiposAjv 8 + ajv-formats
Serialización compiladaRespuestas filtradas por schema (no se escapan campos) y más rápidasfast-json-stringify
HooksEngancharte a cualquier punto del ciclo de vida: 10 hooks de petición y 6 de aplicaciónnúcleo de Fastify
Plugins encapsuladosCada módulo tiene su propio contexto: decoradores, hooks y rutas aisladosavvio
DecoradoresExtender app, request y reply de forma segura y rápidanúcleo de Fastify
Logging integradoJSON estructurado, un id por petición, redacción de secretospino
Testing sin redPeticiones simuladas con inject(), sin abrir puertoslight-my-request
TypeScriptTipos incluidos; con type providers, los tipos salen del schemaTypeBox, json-schema-to-ts, Zod
JSON seguroProtección contra prototype poisoning al parsearsecure-json-parse
HTTP/2 y HTTPSCon una opción al crear la instanciamódulos http2/https de Node
EcosistemaDecenas de plugins oficiales @fastify/* y cientos de la comunidad—

Descarga e instalación

Fastify se descarga desde npm como cualquier paquete de Node.js. Estos son los enlaces oficiales:

RecursoEnlace
Paquete en npm (descarga)npmjs.com/package/fastify
Código fuentegithub.com/fastify/fastify
Versiones y notas de cambiosgithub.com/fastify/fastify/releases
Web y documentación oficialfastify.dev · fastify.dev/docs/latest
Generador de proyectoscreate-fastify (npm init fastify) y fastify-cli
Ecosistema de pluginsfastify.dev/ecosystem
Node.js (requisito: 20 o superior)nodejs.org/es/download

Necesitas Node.js 20 o superior (Fastify 5 dejó de soportar las versiones antiguas). Si tienes una versión vieja, instálate nvm o descarga el instalador de nodejs.org. Luego, en una carpeta vacía:

# Proyecto vacío con Fastify (Node.js 20 o superior)
mkdir mi-api && cd mi-api
npm init -y
npm pkg set type=module      # usamos import/export
npm i fastify

# Comprueba la versión instalada
npm ls fastify
# [email protected]
# └── [email protected]

O con el generador oficial

Si prefieres partir de un esqueleto, el generador crea un proyecto con carga automática de plugins y rutas por carpetas, tests y scripts ya configurados. Lo he probado y los tres tests que trae pasan a la primera:

# Generador oficial (usa fastify-cli por debajo)
npm init fastify@latest mi-api            # JavaScript
npm init fastify@latest mi-api -- --lang=ts   # TypeScript

cd mi-api && npm install
npm run dev     # arranca con recarga automática y logs legibles
npm test        # node:test, ya trae 3 pruebas de ejemplo
mi-api/
├── app.js               # registra @fastify/autoload para plugins/ y routes/
├── plugins/
│   ├── sensible.js      # @fastify/sensible: httpErrors, reply.notFound()...
│   └── support.js       # ejemplo de plugin compartido con fastify-plugin
├── routes/
│   ├── root.js          # GET /
│   └── example/
│       └── index.js     # GET /example  (la carpeta da el prefijo)
└── test/

Para aprender, sin embargo, te recomiendo empezar sin generador: así ves cada pieza. Es lo que haremos aquí.

Versiones y soporte

VersiónPublicadaFin de soporteNode.js
3.xjulio 2020junio 202310 – 18
4.xjunio 202230 junio 202514 – 22
5.x (actual, 5.12.5)septiembre 2024por definir20, 22 (y 24 en la práctica)
6.0 (alpha)en pruebas en septiembre de 2026——

Fastify publica versiones con mucha frecuencia y, ojo, muchas son de seguridad: en septiembre de 2026 salieron la 5.12.2 (cuatro avisos de seguridad) y la 5.12.5 (uno más). Mantén la dependencia al día con npm update o Dependabot. La rama 6 ya está en alpha; si empiezas hoy, usa la 5.

Tu primer servidor: el ejemplo pequeño

Este es el "hola mundo" completo. Guárdalo como hola.js:

// hola.js
import Fastify from 'fastify'

const app = Fastify({ logger: true })

app.get('/', async (request, reply) => {
  return { hola: 'mundo' }
})

try {
  await app.listen({ port: 3000, host: '0.0.0.0' })
} catch (err) {
  app.log.error(err)
  process.exit(1)
}
$ node hola.js
{"level":30,"time":1790703721144,"pid":1997287,"msg":"Server listening at http://127.0.0.1:3000"}

$ curl -s localhost:3000/
{"hola":"mundo"}

# y en el log del servidor, una línea por petición, en JSON:
{"level":30,"reqId":"req-1","req":{"method":"GET","url":"/","host":"localhost:3000",...},"msg":"incoming request"}
{"level":30,"reqId":"req-1","res":{"statusCode":200},"responseTime":5.4,"msg":"request completed"}

Ya hay varias cosas que merecen explicación:

  • El handler devuelve un objeto y Fastify lo convierte a JSON y pone la cabecera content-type: application/json. Con funciones async, lo que devuelves es la respuesta. No hace falta res.json().
  • logger: true activa pino: cada petición genera dos líneas JSON (entrada y salida) con un reqId para seguirla.
  • host: '0.0.0.0' es importante: por defecto Fastify escucha solo en localhost, que dentro de un contenedor Docker significa "inaccesible desde fuera". Volveremos a esto al desplegar.
  • await app.listen() arranca el servidor cuando todos los plugins están cargados. Si algo falla al arrancar (un plugin roto, un puerto ocupado), la promesa se rechaza y el proceso sale con error, que es lo que quieres.
  • Como ves, Fastify también contesta a HEAD automáticamente en cada ruta GET.

Cómo funciona por dentro

Para usar bien Fastify hay que entender cinco piezas: el router, la validación, la serialización, el ciclo de vida con sus hooks y el sistema de plugins. Casi todos los "¿por qué no funciona esto?" se explican con alguna de ellas.

1. El router: find-my-way y el árbol radix

Express guarda las rutas en una lista y, para cada petición, las recorre una a una probando expresiones regulares hasta encontrar la que encaja. Con diez rutas da igual; con trescientas, se nota.

Fastify usa find-my-way, un router basado en un árbol radix (un árbol de prefijos comprimido). Las rutas /usuarios, /usuarios/:id y /usuarios/:id/pedidos comparten el prefijo /usuarios, que se guarda una sola vez. Buscar una ruta es bajar por el árbol carácter a carácter: el coste depende de la longitud de la URL, no del número de rutas. Además, find-my-way separa un árbol por método HTTP y prioriza siempre lo estático sobre lo paramétrico, y lo paramétrico sobre los comodines, así que el orden en que declares las rutas no importa (en Express sí).

Puedes ver el árbol con app.printRoutes(). Este ejemplo recoge casi todo lo que sabe hacer el router:

// router.js: opciones de find-my-way
import Fastify from 'fastify'
const app = Fastify({
  routerOptions: {
    ignoreTrailingSlash: true,   // /usuarios y /usuarios/ son la misma ruta
    caseSensitive: false,        // /Usuarios == /usuarios
    maxParamLength: 200          // parámetros más largos -> 404 (defensa contra abusos)
  }
})
app.get('/usuarios', async () => ['ada', 'grace'])
app.get('/usuarios/:id(^\\d+)', async (req) => ({ numerico: req.params.id }))      // con regex
app.get('/usuarios/:nombre', async (req) => ({ porNombre: req.params.nombre }))
app.get('/fotos/:ancho-:alto.jpg', async (req) => req.params)                    // varios en un segmento
app.get('/archivo/:nombre?', async (req) => ({ nombre: req.params.nombre ?? '(ninguno)' })) // opcional
app.get('/api', { constraints: { version: '2.0.0' } }, async () => ({ v: 2 }))  // por cabecera Accept-Version
app.get('/api', async () => ({ v: 1 }))
await app.ready()
console.log(app.printRoutes({ commonPrefix: false }))
for (const [url, h] of [['/usuarios/'], ['/USUARIOS'], ['/usuarios/42'], ['/usuarios/ada'], ['/fotos/800-600.jpg'], ['/archivo'], ['/archivo/a.txt'], ['/api'], ['/api', { 'accept-version': '2.x' }]]) {
  const r = await app.inject({ url, headers: h }); console.log(url, h ? JSON.stringify(h) : '', '→', r.body)
}
└── /
    ├── usuarios (GET, HEAD)
    │   ├── /:id(^\d+) (GET, HEAD)
    │   └── /:nombre (GET, HEAD)
    ...

/usuarios/          → ["ada","grace"]          (ignoreTrailingSlash)
/USUARIOS           → ["ada","grace"]          (caseSensitive: false)
/usuarios/42        → {"numerico":"42"}        (gana la regex)
/usuarios/ada       → {"porNombre":"ada"}
/fotos/800-600.jpg  → {"ancho":"800","alto":"600"}
/archivo            → {"nombre":"(ninguno)"}
/api                → {"v":1}
/api  (Accept-Version: 2.x) → {"v":2}
💡
Desde Fastify 5 las opciones del router van dentro de routerOptions. Si las pones en la raíz (ignoreTrailingSlash: true a secas), sigue funcionando pero verás el aviso de obsolescencia FSTDEP022: la versión 6 las eliminará.

2. Validación: JSON Schema y Ajv

Cada ruta puede declarar un schema con cuatro partes que se validan antes de que tu código se ejecute: params, querystring, body y headers. El formato es JSON Schema, un estándar, y el validador es Ajv, el más rápido que existe para JavaScript.

El truco del rendimiento está en que Ajv no interpreta el schema en cada petición: al arrancar, lo compila a una función JavaScript hecha a medida (literalmente genera código con new Function). Validar un body es ejecutar esa función, sin recorrer el schema. Por eso Fastify compila todos los schemas en el arranque: si tienes un error en uno, lo sabrás al iniciar, no con la primera petición.

Veamos rutas con todo tipo de validación, incluido un schema reutilizable con $ref:

// rutas.js: parámetros, query string, body y respuesta, todo con JSON Schema
import Fastify from 'fastify'

const app = Fastify({ logger: false })

// Esquema reutilizable: se registra una vez y se referencia con $ref
app.addSchema({
  $id: 'usuario',
  type: 'object',
  properties: {
    id: { type: 'integer' },
    nombre: { type: 'string' },
    email: { type: 'string', format: 'email' }
  }
})

// Ruta con parámetro y validación: :id tiene que ser un entero
app.get('/usuarios/:id', {
  schema: {
    params: {
      type: 'object',
      properties: { id: { type: 'integer', minimum: 1 } },
      required: ['id']
    },
    response: {
      200: { $ref: 'usuario#' }
    }
  }
}, async (request) => {
  // request.params.id ya llega convertido a número (coerceTypes de Ajv)
  return {
    id: request.params.id,
    nombre: 'Ada',
    email: '[email protected]',
    password: 'esto-nunca-sale' // no está en el schema de respuesta: se descarta
  }
})

// Query string con valores por defecto
app.get('/buscar', {
  schema: {
    querystring: {
      type: 'object',
      properties: {
        q: { type: 'string', minLength: 2 },
        pagina: { type: 'integer', minimum: 1, default: 1 },
        porPagina: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
      },
      required: ['q']
    }
  }
}, async (request) => {
  const { q, pagina, porPagina } = request.query
  return { q, pagina, porPagina }
})

// Body validado: si falta algo, Fastify responde 400 sin llegar al handler
app.post('/usuarios', {
  schema: {
    body: {
      type: 'object',
      properties: {
        nombre: { type: 'string', minLength: 1, maxLength: 80 },
        email: { type: 'string', format: 'email' }
      },
      required: ['nombre', 'email'],
      additionalProperties: false
    },
    response: {
      201: { $ref: 'usuario#' }
    }
  }
}, async (request, reply) => {
  reply.code(201)
  return { id: 42, ...request.body }
})

// Rutas con comodín y con varios parámetros
app.get('/ficheros/*', async (request) => ({ ruta: request.params['*'] }))
app.get('/tiendas/:tienda/productos/:sku', async (request) => request.params)

// La forma "completa" con app.route()
app.route({
  method: ['GET', 'HEAD'],
  url: '/salud',
  handler: async () => ({ ok: true })
})

await app.listen({ port: 3000, host: '0.0.0.0' })
$ curl -s localhost:3000/usuarios/7
{"id":7,"nombre":"Ada","email":"[email protected]"}            # ¡sin password!

$ curl -s localhost:3000/usuarios/abc
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"params/id must be integer"}

$ curl -s "localhost:3000/buscar?q=fastify"
{"q":"fastify","pagina":1,"porPagina":20}                     # valores por defecto aplicados

$ curl -s "localhost:3000/buscar?q=f"
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"querystring/q must NOT have fewer than 2 characters"}

$ curl -s -XPOST localhost:3000/usuarios -H 'content-type: application/json' \
    -d '{"nombre":"","email":"no-es-email","admin":true}'
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body/nombre must NOT have fewer than 1 characters"}

$ curl -s localhost:3000/ficheros/a/b/c.txt
{"ruta":"a/b/c.txt"}

Fíjate en tres detalles que sorprenden la primera vez:

  1. /usuarios/7 devuelve id: 7 como número, aunque en la URL todo es texto. Fastify configura Ajv con coerceTypes: 'array': convierte tipos cuando puede ("7" → 7, "true" → true) y convierte un valor suelto en array si el schema pide un array (muy útil en query strings como ?tag=a).
  2. Los valores por defecto se aplican (useDefaults: true): pagina y porPagina aparecen aunque no los mandes.
  3. "admin": true no provoca un error: con removeAdditional: true, Ajv borra en silencio las propiedades que no están en el schema cuando pones additionalProperties: false. El error que ves en la última petición es por nombre vacío, no por admin.

Estos valores por defecto son cómodos, pero conviene conocerlos. Así se comportan, probado:

// ajv.js: Ajv más estricto
import Fastify from 'fastify'

const app = Fastify({
  ajv: {
    customOptions: {
      removeAdditional: false, // campos de más -> 400 en vez de borrarlos en silencio
      coerceTypes: false,      // "12" ya no se convierte en 12 (ojo: afecta a params y query)
      allErrors: true          // todos los errores a la vez (Fastify lo trae apagado: con schemas
                               // mal acotados, un atacante puede forzar mucho trabajo a Ajv)
    }
  }
})

app.post('/u', {
  schema: {
    body: {
      type: 'object',
      required: ['nombre', 'edad'],
      properties: { nombre: { type: 'string', minLength: 2 }, edad: { type: 'integer' } },
      additionalProperties: false
    }
  }
}, async (request) => request.body)

const r = await app.inject({ method: 'POST', url: '/u', payload: { nombre: 'A', edad: '30', admin: true } })
console.log(r.statusCode, r.json().message)
// Con la configuración por defecto de Fastify:
POST /u  {"nombre":"Ada","admin":true}   →  200 {"nombre":"Ada"}   // "admin" se borra en silencio
POST /u  {"nombre":12}                   →  200 {"nombre":"12"}    // 12 se convierte en "12"

// Con el Ajv estricto de arriba:
400 body must NOT have additional properties, body/nombre must NOT have fewer than 2 characters, body/edad must be integer
⚠️
Cuidado con allErrors: true. Fastify lo trae desactivado a propósito: con schemas poco acotados (cadenas sin maxLength, arrays sin maxItems), un atacante puede enviar un body enorme y lleno de errores para obligar a Ajv a trabajar mucho. Si lo activas, pon límites a todo.

3. Serialización: fast-json-stringify

La otra mitad del schema es response: describe lo que devuelve cada código de estado. Fastify lo usa para compilar, con fast-json-stringify, una función que convierte tu objeto en JSON sabiendo de antemano su forma. JSON.stringify tiene que inspeccionar cada valor para averiguar su tipo; la función generada ya sabe que id es un entero y nombre una cadena.

Esto tiene dos consecuencias, y la segunda es la importante:

  • Rendimiento: serializar es más rápido.
  • Seguridad: solo se envían los campos declarados. En el ejemplo de rutas, el handler devolvía password, y no salió en la respuesta. Es la mejor protección que conozco contra la filtración accidental de datos: da igual que alguien haga return usuario con el objeto entero de la base de datos.

Quise medir la ganancia de rendimiento en mi servidor en lugar de repetir lo que dice la documentación. Primero, un micro-benchmark sin red (serializar el mismo objeto millones de veces):

// micro-benchmark sin red: JSON.stringify frente a la función generada por fast-json-stringify
import fjs from 'fast-json-stringify'
const obj = { id: 1, nombre: 'Producto 1', precio: 9.99, stock: 3, etiquetas: ['oferta', 'nuevo'] }
const lista = Array.from({ length: 50 }, (_, i) => ({ ...obj, id: i }))
const item = { type: 'object', properties: { id: { type: 'integer' }, nombre: { type: 'string' }, precio: { type: 'number' }, stock: { type: 'integer' }, etiquetas: { type: 'array', items: { type: 'string' } } } }
const uno = fjs(item), varios = fjs({ type: 'array', items: item })
function medir (nombre, fn, n) {
  for (let i = 0; i < n / 10; i++) fn()
  const t = process.hrtime.bigint(); for (let i = 0; i < n; i++) fn()
  const ns = Number(process.hrtime.bigint() - t) / n
  console.log(nombre.padEnd(34), (1e9 / ns / 1e6).toFixed(2), 'M ops/s')
}
medir('1 objeto  · JSON.stringify', () => JSON.stringify(obj), 2e6)
medir('1 objeto  · fast-json-stringify', () => uno(obj), 2e6)
medir('50 objetos · JSON.stringify', () => JSON.stringify(lista), 1e5)
medir('50 objetos · fast-json-stringify', () => varios(lista), 1e5)
Node v24.21.0
1 objeto   · JSON.stringify          1.48 M ops/s
1 objeto   · fast-json-stringify     2.88 M ops/s
50 objetos · JSON.stringify          0.04 M ops/s
50 objetos · fast-json-stringify     0.05 M ops/s

Con un objeto, la función compilada es casi el doble de rápida; con una lista de 50 objetos, la ventaja baja a un 25 % aproximadamente. Luego lo medí de extremo a extremo, con autocannon, 50 conexiones y la misma lista de 50 productos, con y sin schema de respuesta, varias veces alternando:

// serializacion.js: la misma respuesta, con y sin schema de respuesta
import Fastify from 'fastify'

const app = Fastify({ logger: false })

const productos = Array.from({ length: 50 }, (_, i) => ({
  id: i + 1,
  nombre: `Producto ${i + 1}`,
  precio: 9.99 + i,
  stock: i * 3,
  etiquetas: ['oferta', 'nuevo'],
  costeInterno: 4.2 // dato que no debería salir nunca
}))

// Sin schema: JSON.stringify de todo el objeto (incluido costeInterno)
app.get('/sin-schema', async () => productos)

// Con schema: fast-json-stringify genera una función a medida
app.get('/con-schema', {
  schema: {
    response: {
      200: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            id: { type: 'integer' },
            nombre: { type: 'string' },
            precio: { type: 'number' },
            stock: { type: 'integer' },
            etiquetas: { type: 'array', items: { type: 'string' } }
          }
        }
      }
    }
  }
}, async () => productos)

await app.listen({ port: 3000, host: '0.0.0.0' })

Resultado honesto: en HTTP, la diferencia se quedó dentro del ruido (entre 7.000 y 9.700 peticiones por segundo en ambos casos, en un VPS de 4 núcleos que además estaba ejecutando otras cosas). Cuando la petición completa incluye red, parseo de cabeceras y el resto del ciclo, serializar es una parte pequeña del coste. Mi conclusión: pon schemas de respuesta por seguridad y por documentación; la velocidad es un extra que notarás sobre todo en respuestas pequeñas y muy frecuentes.

4. El ciclo de vida y los hooks

Cada petición recorre siempre los mismos pasos, y en muchos de ellos puedes engancharte con un hook. Este diagrama es el mapa que conviene tener en la cabeza:

Diagrama del ciclo de vida de una petición en Fastify: petición, router, onRequest, preParsing, parseo, preValidation, validación, preHandler, handler, preSerialization, serialización, onSend, respuesta y onResponse, con la rama de errores
Los pasos de Fastify (gris), los hooks donde puedes meter tu código (índigo) y tu handler (verde). Diagrama: devutilites.
HookCuándoPara qué lo uso
onRequestNada más llegar; sin bodyAutenticación por cabecera, bloquear IPs, medir tiempos
preParsingAntes de leer el bodyDescomprimir o transformar el stream
preValidationBody leído, sin validarNormalizar datos (trim, minúsculas)
preHandlerTodo validadoAutorización, cargar el usuario de la base de datos
preSerializationHandler terminado, objeto sin serializarEnvolver respuestas ({ data: ... })
onSendPayload ya es texto/Buffer/streamCabeceras finales, comprimir, firmar
onResponseRespuesta enviadaMétricas, auditoría
onErrorCuando algo lanzaMandar el error a Sentry (no cambia la respuesta)
onTimeout / onRequestAbortTimeout del socket / el cliente se vaLiberar recursos, registrar cancelaciones
onReady, onListen, onClose, preClose, onRoute, onRegisterHooks de aplicaciónMigraciones al arrancar, cerrar conexiones, auditar rutas

Este ejemplo usa casi todos a la vez, para que veas en qué orden se ejecutan y qué puede hacer cada uno:

// hooks.js: el ciclo de vida de una petición, paso a paso
import Fastify from 'fastify'

const app = Fastify({ logger: { level: 'info' } })

// 1. onRequest: lo primero que se ejecuta. Aún no hay body.
app.addHook('onRequest', async (request, reply) => {
  request.inicio = process.hrtime.bigint()
  if (request.headers['x-bloqueado'] === 'si') {
    // Responder desde un hook corta la cadena: el handler no se ejecuta
    return reply.code(403).send({ error: 'Prohibido' })
  }
})

// 2. preParsing / 3. preValidation: antes de parsear y validar el body
app.addHook('preValidation', async (request) => {
  if (request.body && typeof request.body.nombre === 'string') {
    request.body.nombre = request.body.nombre.trim()
  }
})

// 4. preHandler: ya está todo validado; el sitio típico para autorización
app.addHook('preHandler', async (request, reply) => {
  request.log.info({ ruta: request.routeOptions.url }, 'entrando al handler')
})

// 5. preSerialization: transforma el objeto antes de convertirlo a JSON
app.addHook('preSerialization', async (request, reply, payload) => {
  if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
    return { ...payload, servidoPor: 'fastify' }
  }
  return payload
})

// 6. onSend: ya es texto (o Buffer/stream). Útil para cabeceras finales
app.addHook('onSend', async (request, reply, payload) => {
  const ms = Number(process.hrtime.bigint() - request.inicio) / 1e6
  reply.header('x-tiempo-ms', ms.toFixed(2))
  return payload
})

// 7. onResponse: la respuesta ya se ha enviado. Métricas, auditoría...
app.addHook('onResponse', async (request, reply) => {
  request.log.info({ status: reply.statusCode, ms: reply.elapsedTime.toFixed(2) }, 'fin')
})

// onError: se ejecuta si algo lanza una excepción (solo para observar)
app.addHook('onError', async (request, reply, error) => {
  request.log.warn({ err: error.message }, 'algo ha fallado')
})

app.post('/saludo', {
  schema: {
    body: {
      type: 'object',
      properties: { nombre: { type: 'string', minLength: 1 } },
      required: ['nombre']
    }
  },
  // Los hooks también se pueden declarar solo para una ruta
  onRequest: async (request) => { request.log.info('hook exclusivo de /saludo') }
}, async (request) => {
  return { mensaje: `Hola, ${request.body.nombre}` }
})

app.get('/roto', async () => { throw new Error('ups') })

await app.listen({ port: 3000, host: '0.0.0.0' })
$ curl -si -XPOST localhost:3000/saludo -H 'content-type: application/json' -d '{"nombre":"   Linus   "}'
HTTP/1.1 200 OK
x-tiempo-ms: 7.77
{"mensaje":"Hola, Linus","servidoPor":"fastify"}      # trim() de preValidation + campo de preSerialization

$ curl -s -XPOST localhost:3000/saludo -H 'x-bloqueado: si' ...
{"error":"Prohibido","servidoPor":"fastify"}         # cortado en onRequest (y aun así pasa por preSerialization)

$ curl -s localhost:3000/roto
{"statusCode":500,"error":"Internal Server Error","message":"ups"}

# En el log, en orden: "hook exclusivo de /saludo" → "entrando al handler" → "fin" (status 200, 13 ms)

Reglas que conviene grabarse:

  • Un hook async no recibe done; uno con callback, sí. No mezcles los dos estilos en la misma función: o async o done().
  • Si un hook responde (return reply.send(...)), la cadena se corta: el handler no se ejecuta, pero los hooks de salida sí (preSerialization, onSend, onResponse). Por eso la respuesta "Prohibido" llevaba servidoPor.
  • onError no puede cambiar la respuesta: solo sirve para observar. Para dar forma al error se usa setErrorHandler.
  • Los hooks se pueden declarar para una sola ruta (en sus opciones) o para un plugin; en ese caso solo afectan a las rutas de ese plugin. Esto nos lleva a la pieza más importante.

5. Plugins, encapsulación y avvio

En Fastify todo es un plugin: tus rutas, tu conexión a la base de datos, tu autenticación. Un plugin es simplemente una función async (instancia, opciones) => {} que se registra con app.register(). Lo que lo hace especial es la encapsulación: cada register() crea un contexto hijo que hereda todo lo del padre (decoradores, hooks, schemas), pero lo que se añade dentro no sube al padre ni se ve desde los hermanos.

Árbol de contextos de Fastify: la raíz con tres plugins hijos; el envuelto con fastify-plugin decora la raíz, los otros dos están aislados entre sí
La herencia va de arriba abajo; fastify-plugin es la única forma de que algo "suba". Diagrama: devutilites.
// encapsulacion.js: cada register() crea un contexto hijo
import Fastify from 'fastify'
import fp from 'fastify-plugin'

const app = Fastify()

// Plugin SIN fastify-plugin: lo que decore se queda dentro de su contexto
async function pluginPrivado (instancia, opts) {
  instancia.decorate('secreto', 'solo lo veo yo y mis hijos')
  instancia.get('/privado', async function () {
    return { secreto: this.secreto } // "this" es la instancia del contexto
  })
}

// Plugin CON fastify-plugin: "se salta" la encapsulación y decora al padre
const utilidades = fp(async function (instancia, opts) {
  instancia.decorate('formatearPrecio', (centimos) =>
    new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(centimos / 100))
}, { name: 'utilidades' })

app.register(utilidades)
app.register(pluginPrivado)

// Un plugin hermano con prefijo: ve formatearPrecio, pero no secreto
app.register(async function tienda (instancia) {
  instancia.get('/precio', async () => ({
    precio: instancia.formatearPrecio(1234567),
    veoElSecreto: instancia.hasDecorator('secreto')
  }))
}, { prefix: '/tienda' })

await app.ready()
console.log(app.printRoutes())
console.log(app.printPlugins())
for (const url of ['/privado', '/tienda/precio']) {
  const r = await app.inject(url)
  console.log(url, r.body)
}
console.log('raíz ve secreto:', app.hasDecorator('secreto'), '| raíz ve formatearPrecio:', app.hasDecorator('formatearPrecio'))
$ node encapsulacion.js
└── /
    ├── privado (GET, HEAD)
    └── tienda/precio (GET, HEAD)

root 12 ms
├── utilidades 0 ms
├─┬ pluginPrivado 2 ms
└─┬ tienda 1 ms

/privado {"secreto":"solo lo veo yo y mis hijos"}
/tienda/precio {"precio":"12.345,67 €","veoElSecreto":false}
raíz ve secreto: false | raíz ve formatearPrecio: true

¿Por qué tanta ceremonia? Porque te permite, por ejemplo, poner un hook de autenticación solo en el plugin /admin sin tocar el resto de la API, o tener dos versiones de la API con decoradores distintos que no se pisan. Es lo que evita el problema clásico de Express, donde un app.use() mal colocado afecta a todas las rutas.

La carga la hace avvio, un cargador de plugins asíncrono que garantiza el orden: un plugin no empieza a cargarse hasta que el anterior ha terminado, y ready()/listen() esperan a que termine todo el árbol. printPlugins() te enseña ese árbol con el tiempo de carga de cada plugin, muy útil para encontrar el que tarda en arrancar.

🧩
Cuándo usar fastify-plugin: cuando el plugin existe para compartir algo con el resto de la aplicación: una conexión a la base de datos, un decorador de utilidad, un hook global. Cuándo no: en plugins de rutas; ahí quieres justo la encapsulación. Dale siempre un name para que los errores y printPlugins() sean legibles.

6. Decoradores

Los decoradores son la forma oficial de añadir cosas a la instancia (decorate), a la petición (decorateRequest) y a la respuesta (decorateReply). ¿Por qué no hacer simplemente request.usuario = ...? Por rendimiento: V8 optimiza los objetos según su "forma" (sus propiedades). Si a veces añades usuario y a veces no, cada petición tiene una forma distinta y V8 deja de optimizar. decorateRequest('usuario', null) reserva la propiedad en el prototipo desde el principio, así que todas las peticiones tienen la misma forma.

Dos normas: el valor inicial de decorateRequest debe ser null o un tipo primitivo (nunca un objeto, que se compartiría entre todas las peticiones: Fastify te lo impide), y si necesitas un objeto por petición, créalo en un hook onRequest. Lo verás en acción en la sección de seguridad.

7. Logging con pino

Fastify trae pino integrado: escribe JSON, una línea por evento, y es muy rápido porque delega el formateo "bonito" a otro proceso. request.log es un logger hijo que añade el reqId automáticamente, así que puedes seguir todas las líneas de una misma petición. Una configuración que uso en producción:

// logs.js: pino, el logger integrado
import Fastify, { LogController } from 'fastify'
import { randomUUID } from 'node:crypto'

const app = Fastify({
  logger: {
    level: process.env.LOG_LEVEL ?? 'info',
    // Nunca escribas contraseñas ni tokens en los logs
    redact: ['req.headers.authorization', 'req.headers.cookie', 'body.clave'],
    serializers: {
      // Por defecto se registran método, url, host, IP...; aquí añadimos el user-agent
      req (request) {
        return { method: request.method, url: request.url, ua: request.headers['user-agent'] }
      }
    }
  },
  // Reutiliza el id que manda el proxy (o genera uno): así se siguen las peticiones entre servicios
  requestIdHeader: 'x-request-id',
  genReqId: () => randomUUID(),
  // Fastify 5.12: el registro de cada petición se controla con un LogController
  logController: new LogController({
    disableRequestLogging: (request) => request.url === '/salud' // el health check no ensucia los logs
  })
})

app.get('/salud', async () => ({ ok: true }))

app.post('/login', async (request) => {
  // request.log es un "hijo" que añade reqId a cada línea automáticamente
  request.log.info({ body: request.body }, 'intento de login')
  const log = request.log.child({ modulo: 'auth' })
  log.debug('esto solo sale con LOG_LEVEL=debug')
  log.warn({ usuario: request.body.usuario }, 'contraseña incorrecta')
  return { ok: false }
})

await app.inject('/salud')
await app.inject({
  method: 'POST', url: '/login',
  headers: { authorization: 'Bearer secreto', 'user-agent': 'curl/8.5', 'x-request-id': 'abc-123' },
  payload: { usuario: 'ada', clave: 'lovelace' }
})
{"level":30,"reqId":"abc-123","req":{"method":"POST","url":"/login","ua":"curl/8.5"},"msg":"incoming request"}
{"level":30,"reqId":"abc-123","body":{"usuario":"ada","clave":"[Redacted]"},"msg":"intento de login"}
{"level":40,"reqId":"abc-123","modulo":"auth","usuario":"ada","msg":"contraseña incorrecta"}
{"level":30,"reqId":"abc-123","res":{"statusCode":200},"responseTime":8.9,"msg":"request completed"}
// La petición a /salud no aparece: la filtra el LogController
  • redact sustituye por [Redacted] las rutas que indiques: cabeceras de autenticación, cookies, contraseñas. Nunca más una clave en los logs.
  • requestIdHeader reutiliza el id que llega del proxy o de otro servicio. Si no llega, genReqId genera un UUID.
  • LogController es nuevo en Fastify 5.12: controla las líneas de log automáticas. Aquí lo uso para no registrar el health check, que en Coolify se llama cada pocos segundos. La antigua opción disableRequestLogging a secas funciona, pero lanza el aviso FSTDEP023.
  • En desarrollo, transport: { target: 'pino-pretty' } muestra los logs con colores. En producción, deja el JSON: Coolify, Loki o cualquier agregador lo entienden.

Manejo de errores

Fastify distingue tres situaciones: un error de validación (400 automático), un error que lanzas tú a propósito y un error inesperado (un bug). Para las dos últimas conviene tener errores con código propio y un único manejador que dé forma a todas las respuestas. @fastify/error crea clases de error con código, mensaje con formato estilo printf y status HTTP:

// errores.js: errores propios, manejador global y 404
import Fastify from 'fastify'
import createError from '@fastify/error'

// Errores con código, mensaje con formato y status HTTP
const NoEncontrado = createError('APP_NO_ENCONTRADO', 'No existe el recurso %s', 404)
const SinSaldo = createError('APP_SIN_SALDO', 'Saldo insuficiente: faltan %d €', 409)

const app = Fastify({ logger: false })

app.get('/pedidos/:id', async (request) => {
  throw new NoEncontrado(`pedido ${request.params.id}`)
})

app.post('/pagar', async () => {
  throw new SinSaldo(15)
})

app.get('/bug', async () => {
  const cliente = undefined
  return cliente.nombre // TypeError: un fallo de verdad
})

app.post('/validado', {
  schema: { body: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' } } } }
}, async () => ({ ok: true }))

// Un único sitio para dar forma a TODAS las respuestas de error
app.setErrorHandler((error, request, reply) => {
  // 1. Errores de validación del schema
  if (error.validation) {
    return reply.code(400).send({
      error: 'datos_invalidos',
      detalles: error.validation.map(v => ({ campo: v.instancePath || v.params.missingProperty, problema: v.message }))
    })
  }
  // 2. Errores "esperados" (4xx): se enseñan tal cual
  const status = error.statusCode ?? 500
  if (status < 500) {
    return reply.code(status).send({ error: error.code, mensaje: error.message })
  }
  // 3. Errores inesperados: se registran con todo detalle, pero al cliente no se le cuenta nada
  request.log.error({ err: error }, 'error no controlado')
  return reply.code(500).send({ error: 'interno', mensaje: 'Algo ha ido mal. Ya lo estamos mirando.' })
})

// Rutas que no existen
app.setNotFoundHandler((request, reply) => {
  reply.code(404).send({ error: 'ruta_no_encontrada', ruta: `${request.method} ${request.url}` })
})

const pruebas = [
  ['GET', '/pedidos/77'], ['POST', '/pagar'], ['GET', '/bug'],
  ['POST', '/validado', { email: 'nope' }], ['POST', '/validado', {}], ['GET', '/no-existe']
]
for (const [method, url, payload] of pruebas) {
  const r = await app.inject({ method, url, payload })
  console.log(method, url, '→', r.statusCode, r.body)
}
GET /pedidos/77   → 404 {"error":"APP_NO_ENCONTRADO","mensaje":"No existe el recurso pedido 77"}
POST /pagar       → 409 {"error":"APP_SIN_SALDO","mensaje":"Saldo insuficiente: faltan 15 €"}
GET /bug          → 500 {"error":"interno","mensaje":"Algo ha ido mal. Ya lo estamos mirando."}
POST /validado    → 400 {"error":"datos_invalidos","detalles":[{"campo":"/email","problema":"must match format \"email\""}]}
POST /validado    → 400 {"error":"datos_invalidos","detalles":[{"campo":"email","problema":"must have required property 'email'"}]}
GET /no-existe    → 404 {"error":"ruta_no_encontrada","ruta":"GET /no-existe"}

El patrón importante está en el manejador: los errores 4xx se enseñan al cliente, porque son culpa suya y necesita saber qué corregir; los 5xx se registran con todo detalle en el log, pero al cliente solo le llega un mensaje genérico. Por defecto, Fastify devuelve el mensaje del error también en los 500 (en el ejemplo de hooks, /roto respondía "message":"ups"), y eso puede filtrar detalles internos.

Dos trampas que me hicieron perder un rato

Montando la API de ejemplo de este artículo, mis tests de errores fallaban con un 500 en lugar de un 404. Había dos problemas encadenados, y los dos son de manual:

Trampa 1: el schema de respuesta también se aplica a los errores. Si declaras response: { 404: ... }, el cuerpo del 404 tiene que cumplir ese schema. El manejador de errores por defecto de Fastify devuelve {statusCode, code, error, message}; si tu schema exige otro campo, la serialización falla y acabas con un 500:

// trampa404.js: el schema de respuesta también se aplica a los errores
import Fastify from 'fastify'

const app = Fastify({ logger: { level: 'error' } })

app.get('/x', {
  schema: {
    response: {
      404: {
        type: 'object',
        required: ['mensaje'],                 // mi formato de error...
        properties: { mensaje: { type: 'string' } }
      }
    }
  }
}, async () => {
  const error = new Error('no está')
  error.statusCode = 404
  throw error                                  // ...pero el manejador por defecto responde otro
})

const r = await app.inject('/x')
console.log(r.statusCode, r.body)
500 {"statusCode":500,"code":"FST_ERR_FAILED_ERROR_SERIALIZATION",
     "message":"Failed to serialize an error. Error: \"mensaje\" is required!. Original error: no está"}

Trampa 2: setErrorHandler está encapsulado y cuenta el orden. Yo lo había puesto al final de construirApp(), después de registrar las rutas con await app.register(...). Pero cada contexto hijo hereda el manejador que exista en el momento de crearse, así que mis rutas seguían usando el de por defecto, que devolvía un cuerpo incompatible con mi schema de 404 (trampa 1). Solución: declarar setErrorHandler antes de registrar los plugins de rutas. Lo verás así en el código de la API.

Seguridad: CORS, cabeceras, límites y JWT

Cuatro plugins oficiales cubren lo básico de cualquier API pública. En este ejemplo verás también decorate y decorateRequest trabajando juntos para proteger rutas:

// seguridad.js: CORS, cabeceras, límite de peticiones y JWT
import Fastify from 'fastify'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import jwt from '@fastify/jwt'

const app = Fastify({ logger: false })

await app.register(helmet)                        // cabeceras de seguridad
await app.register(cors, {
  origin: ['https://devutilites.com', /\.devutilites\.com$/],
  credentials: true
})
await app.register(rateLimit, {
  max: 100,                                       // 100 peticiones...
  timeWindow: '1 minute'                          // ...por minuto y por IP
})
await app.register(jwt, {
  secret: process.env.JWT_SECRET ?? 'cambia-esto-por-un-secreto-largo'
})

// decorateRequest: reserva la propiedad en el prototipo (más rápido que añadirla a mano)
app.decorateRequest('usuario', null)

// decorate: un "preHandler" reutilizable para las rutas protegidas
app.decorate('autenticar', async function (request, reply) {
  try {
    const datos = await request.jwtVerify()        // lee "Authorization: Bearer ..."
    request.usuario = { id: datos.sub, rol: datos.rol }
  } catch {
    return reply.code(401).send({ error: 'token_invalido' })
  }
})

app.post('/login', {
  config: { rateLimit: { max: 5, timeWindow: '1 minute' } }, // más estricto en el login
  schema: {
    body: {
      type: 'object',
      required: ['usuario', 'clave'],
      properties: { usuario: { type: 'string' }, clave: { type: 'string' } }
    }
  }
}, async (request, reply) => {
  const { usuario, clave } = request.body
  if (usuario !== 'ada' || clave !== 'lovelace') {   // aquí iría tu base de datos
    return reply.code(401).send({ error: 'credenciales_incorrectas' })
  }
  const token = await reply.jwtSign({ sub: '1', rol: 'admin' }, { expiresIn: '1h' })
  return { token }
})

app.get('/perfil', { onRequest: [app.autenticar] }, async (request) => {
  return { usuario: request.usuario }
})

await app.listen({ port: 3000, host: '0.0.0.0' })
$ curl -s localhost:3000/perfil
{"error":"token_invalido"}

$ TOKEN=$(curl -s -XPOST localhost:3000/login -H 'content-type: application/json' \
    -d '{"usuario":"ada","clave":"lovelace"}' | jq -r .token)
$ curl -s localhost:3000/perfil -H "authorization: Bearer $TOKEN"
{"usuario":{"id":"1","rol":"admin"}}

# Cabeceras que añaden helmet, cors y rate-limit:
Content-Security-Policy: default-src 'self';base-uri 'self';...
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: SAMEORIGIN
access-control-allow-origin: https://blog.devutilites.com
x-ratelimit-limit: 100
x-ratelimit-remaining: 98

# Siete intentos de login con la clave mal, seguidos:
401 401 401 401 429 429 429
{"statusCode":429,"error":"Too Many Requests","message":"Rate limit exceeded, retry in 1 minute"}
  • @fastify/helmet añade una política de seguridad de contenido (CSP), HSTS, protección contra clickjacking y otras cabeceras. Si sirves una interfaz web desde el mismo servidor (como Swagger UI), tendrás que relajar la CSP.
  • @fastify/cors acepta cadenas, expresiones regulares y funciones en origin. Nunca pongas origin: '*' con credentials: true.
  • @fastify/rate-limit permite límites globales y por ruta (config.rateLimit). En el login lo dejé en 5 por minuto: la sexta petición ya recibe un 429. Con varias instancias, usa su opción redis para compartir los contadores.
  • @fastify/jwt añade reply.jwtSign() y request.jwtVerify(). El secreto, siempre desde una variable de entorno y largo.

El patrón app.decorate('autenticar', ...) + onRequest: [app.autenticar] es el más habitual para proteger rutas sueltas. Si quieres proteger un grupo entero, mete esas rutas en un plugin y añade dentro instancia.addHook('onRequest', instancia.autenticar): la encapsulación hará que solo afecte a ese grupo.

🚨
Detrás de un proxy, activa trustProxy. En Coolify, Traefik está delante de tu aplicación; con Cloudflare, además, hay otro salto. Sin trustProxy, request.ip es la IP del proxy y el límite de peticiones castigaría a todos tus usuarios a la vez como si fueran uno.
// Detrás de Traefik/Coolify o Cloudflare, la IP real viene en X-Forwarded-For
const app = Fastify({
  logger: true,
  trustProxy: true   // o la lista de IPs de tus proxies, más seguro
})
// Sin esto, request.ip es la del proxy y @fastify/rate-limit
// trataría a todos tus usuarios como si fueran uno solo

Proyecto completo: una API de tareas en TypeScript

Ahora juntamos todo en una API real con CRUD completo, estructurada como lo haría en un proyecto de verdad: la construcción de la app separada del arranque (para poder testearla), un plugin compartido para los datos, un plugin de rutas encapsulado, TypeScript con los tipos inferidos de los schemas, documentación OpenAPI automática, tests y apagado ordenado.

api-tareas/
├── package.json
├── tsconfig.json
├── Dockerfile
├── src/
│   ├── schemas.ts          # schemas TypeBox = validación + tipos + docs
│   ├── plugins/almacen.ts  # datos compartidos (fastify-plugin)
│   ├── routes/tareas.ts    # CRUD encapsulado bajo /tareas
│   ├── app.ts              # construirApp(): registra todo, no escucha
│   └── server.ts           # arranca y gestiona el apagado
└── test/
    └── tareas.test.ts      # node:test + inject()
{
  "name": "api-tareas",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "@fastify/cors": "^11.3.0",
    "@fastify/error": "^4.2.0",
    "@fastify/helmet": "^13.1.1",
    "@fastify/swagger": "^9.9.0",
    "@fastify/swagger-ui": "^6.1.1",
    "@fastify/type-provider-typebox": "^6.1.0",
    "close-with-grace": "^2.5.0",
    "fastify": "^5.12.5",
    "fastify-plugin": "^6.0.0",
    "typebox": "^1.3.34"
  },
  "devDependencies": {
    "@types/node": "^26.6.3",
    "pino-pretty": "^13.1.3",
    "tsx": "^4.23.15",
    "typescript": "^7.0.2"
  }
}
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true
  },
  "include": ["src"]
}

Los schemas con TypeBox: un solo sitio para todo

El problema de usar JSON Schema con TypeScript es que acabas escribiendo cada cosa dos veces: el schema y la interfaz. Los type providers lo resuelven: TypeBox construye el JSON Schema con funciones (Type.Object, Type.String...) y TypeScript deduce el tipo. Fastify, a través de @fastify/type-provider-typebox, usa ese tipo para request.body, request.params, etc. Si cambias el schema, cambian los tipos. Alternativas equivalentes: json-schema-to-ts o fastify-type-provider-zod si ya usas Zod.

// src/schemas.ts: los schemas son a la vez validación, tipos y documentación
import { Type, type Static } from '@fastify/type-provider-typebox'

export const Tarea = Type.Object({
  id: Type.Integer(),
  titulo: Type.String(),
  hecha: Type.Boolean(),
  creadaEn: Type.String({ format: 'date-time' })
}, { $id: 'Tarea' })

export const NuevaTarea = Type.Object({
  titulo: Type.String({ minLength: 1, maxLength: 200 }),
  hecha: Type.Optional(Type.Boolean({ default: false }))
}, { additionalProperties: false })

export const CambiosTarea = Type.Partial(NuevaTarea, { additionalProperties: false, minProperties: 1 })

export const ParamsId = Type.Object({ id: Type.Integer({ minimum: 1 }) })

export const FiltroTareas = Type.Object({
  hecha: Type.Optional(Type.Boolean()),
  limite: Type.Integer({ minimum: 1, maximum: 100, default: 20 })
})

export const ErrorRespuesta = Type.Object({ error: Type.String(), mensaje: Type.String() })

export type TareaT = Static<typeof Tarea>
export type NuevaTareaT = Static<typeof NuevaTarea>
⚠️
El type provider de TypeBox 6.x usa el paquete typebox (versión 1.x), no el antiguo @sinclair/typebox. Muchos tutoriales siguen con el viejo; si instalas los dos, los tipos no encajan. Instala npm i typebox @fastify/type-provider-typebox.

El plugin de datos

Usa fastify-plugin porque queremos que app.almacen esté disponible en toda la aplicación. El declare module le cuenta a TypeScript que la instancia tiene esa propiedad. Para el ejemplo guarda los datos en memoria; más abajo verás la versión con PostgreSQL.

// src/plugins/almacen.ts: el "repositorio" de tareas, compartido por toda la app
import fp from 'fastify-plugin'
import type { TareaT, NuevaTareaT } from '../schemas.js'

export interface Almacen {
  listar (filtro: { hecha?: boolean, limite: number }): TareaT[]
  obtener (id: number): TareaT | undefined
  crear (datos: NuevaTareaT): TareaT
  actualizar (id: number, cambios: Partial<NuevaTareaT>): TareaT | undefined
  borrar (id: number): boolean
}

// Así TypeScript sabe que app.almacen existe en todas partes
declare module 'fastify' {
  interface FastifyInstance { almacen: Almacen }
}

export default fp(async (app) => {
  // En memoria para el ejemplo; en producción, @fastify/postgres (más abajo)
  const tareas = new Map<number, TareaT>()
  let siguienteId = 1

  const almacen: Almacen = {
    listar ({ hecha, limite }) {
      return [...tareas.values()]
        .filter(t => hecha === undefined || t.hecha === hecha)
        .slice(0, limite)
    },
    obtener: (id) => tareas.get(id),
    crear ({ titulo, hecha = false }) {
      const tarea = { id: siguienteId++, titulo, hecha, creadaEn: new Date().toISOString() }
      tareas.set(tarea.id, tarea)
      return tarea
    },
    actualizar (id, cambios) {
      const tarea = tareas.get(id)
      if (!tarea) return undefined
      Object.assign(tarea, cambios)
      return tarea
    },
    borrar: (id) => tareas.delete(id)
  }

  app.decorate('almacen', almacen)
  app.addHook('onClose', async () => { tareas.clear() })
}, { name: 'almacen' })

Las rutas: el CRUD

Aquí se ve la gracia del type provider: request.query, request.params.id y request.body están tipados sin escribir ni una interfaz. Si intentas leer request.body.titulo en una ruta sin body, TypeScript protesta.

// src/routes/tareas.ts: CRUD completo con tipos inferidos de los schemas
import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import { Type } from '@fastify/type-provider-typebox'
import createError from '@fastify/error'
import { Tarea, NuevaTarea, CambiosTarea, ParamsId, FiltroTareas, ErrorRespuesta } from '../schemas.js'

const TareaNoEncontrada = createError('TAREA_NO_ENCONTRADA', 'No existe la tarea %s', 404)

const rutas: FastifyPluginAsyncTypebox = async (app) => {
  // GET /tareas?hecha=true&limite=10
  app.get('/', {
    schema: {
      tags: ['tareas'],
      summary: 'Lista las tareas',
      querystring: FiltroTareas,
      response: { 200: Type.Array(Type.Ref('Tarea')) }
    }
  }, async (request) => {
    // request.query es { hecha?: boolean, limite: number }: sin escribir ningún tipo
    return app.almacen.listar(request.query)
  })

  // GET /tareas/:id
  app.get('/:id', {
    schema: {
      tags: ['tareas'],
      params: ParamsId,
      response: { 200: Type.Ref('Tarea'), 404: ErrorRespuesta }
    }
  }, async (request) => {
    const tarea = app.almacen.obtener(request.params.id) // id: number
    if (!tarea) throw new TareaNoEncontrada(request.params.id)
    return tarea
  })

  // POST /tareas
  app.post('/', {
    schema: {
      tags: ['tareas'],
      body: NuevaTarea,
      response: { 201: Type.Ref('Tarea') }
    }
  }, async (request, reply) => {
    const tarea = app.almacen.crear(request.body)
    return reply.code(201).header('location', `/tareas/${tarea.id}`).send(tarea)
  })

  // PATCH /tareas/:id
  app.patch('/:id', {
    schema: {
      tags: ['tareas'],
      params: ParamsId,
      body: CambiosTarea,
      response: { 200: Type.Ref('Tarea'), 404: ErrorRespuesta }
    }
  }, async (request) => {
    const tarea = app.almacen.actualizar(request.params.id, request.body)
    if (!tarea) throw new TareaNoEncontrada(request.params.id)
    return tarea
  })

  // DELETE /tareas/:id
  app.delete('/:id', {
    schema: {
      tags: ['tareas'],
      params: ParamsId,
      response: { 204: Type.Null(), 404: ErrorRespuesta }
    }
  }, async (request, reply) => {
    if (!app.almacen.borrar(request.params.id)) throw new TareaNoEncontrada(request.params.id)
    return reply.code(204).send(null)
  })
}

export default rutas

La aplicación y el arranque

construirApp() crea la instancia pero no la pone a escuchar. Así los tests pueden construir una aplicación nueva para cada prueba sin abrir puertos. Fíjate en el orden: el manejador de errores va antes que las rutas (trampa 2), y refResolver hace que el documento OpenAPI use el nombre Tarea en vez de def-0.

// src/app.ts: construye la aplicación, sin arrancarla (así se puede testear)
import Fastify, { type FastifyError, type FastifyServerOptions } from 'fastify'
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import helmet from '@fastify/helmet'
import cors from '@fastify/cors'
import swagger from '@fastify/swagger'
import swaggerUi from '@fastify/swagger-ui'
import almacen from './plugins/almacen.js'
import rutasTareas from './routes/tareas.js'
import { Tarea } from './schemas.js'

export async function construirApp (opts: FastifyServerOptions = {}) {
  const app = Fastify(opts).withTypeProvider<TypeBoxTypeProvider>()

  app.addSchema(Tarea) // disponible como Type.Ref('Tarea') en todas las rutas

  await app.register(helmet, { contentSecurityPolicy: false }) // la UI de Swagger necesita scripts en línea
  await app.register(cors, { origin: true })

  await app.register(swagger, {
    openapi: {
      info: { title: 'API de tareas', version: '1.0.0' },
      tags: [{ name: 'tareas', description: 'Gestión de tareas' }]
    },
    // Usa el $id de cada schema ("Tarea") como nombre en el documento, en vez de def-0
    refResolver: { buildLocalReference: (json, _base, _frag, i) => String(json.$id ?? `def-${i}`) }
  })
  await app.register(swaggerUi, { routePrefix: '/docs' })

  // OJO: antes de registrar las rutas. Los contextos hijos heredan el manejador
  // que exista en el momento de crearse; si lo pones al final, no les llega.
  app.setErrorHandler<FastifyError>((error, request, reply) => {
    if (error.validation) {
      return reply.code(400).send({ error: 'datos_invalidos', mensaje: error.message })
    }
    const status = error.statusCode ?? 500
    if (status >= 500) request.log.error({ err: error }, 'error no controlado')
    return reply.code(status).send({
      error: error.code ?? 'interno',
      mensaje: status >= 500 ? 'Error interno' : error.message
    })
  })

  await app.register(almacen)
  await app.register(rutasTareas, { prefix: '/tareas' })

  app.get('/salud', { schema: { hide: true } }, async () => ({ ok: true }))

  return app
}
// src/server.ts: arranque y apagado ordenado
import closeWithGrace from 'close-with-grace'
import { construirApp } from './app.js'

const app = await construirApp({
  logger: process.env.NODE_ENV === 'production'
    ? { level: 'info' }                                   // JSON puro en producción
    : { level: 'debug', transport: { target: 'pino-pretty' } }
})

// Ante SIGINT/SIGTERM (p. ej. un despliegue en Coolify): deja de aceptar
// conexiones, termina las peticiones en curso y ejecuta los hooks onClose
closeWithGrace({ delay: 10_000 }, async ({ signal, err }) => {
  if (err) app.log.error({ err }, 'cerrando por un error')
  else app.log.info({ signal }, 'cerrando')
  await app.close()
})

await app.listen({
  port: Number(process.env.PORT ?? 3000),
  host: '0.0.0.0' // imprescindible dentro de un contenedor
})

close-with-grace escucha SIGINT, SIGTERM y los errores no capturados, y llama a app.close(). Eso deja de aceptar conexiones, espera a que terminen las peticiones en curso y ejecuta todos los hooks onClose (en nuestro caso, vaciar el almacén; en uno real, cerrar la base de datos). Con delay le das un máximo de 10 segundos antes de salir a la fuerza. Es lo que necesita Coolify para que un despliegue nuevo no corte peticiones a medias.

$ npm run build && PORT=3000 NODE_ENV=production npm start

$ curl -s -XPOST localhost:3000/tareas -H 'content-type: application/json' -d '{"titulo":"probar en real"}'
{"id":1,"titulo":"probar en real","hecha":false,"creadaEn":"2026-09-29T17:45:08.805Z"}

$ curl -s "localhost:3000/tareas?limite=5"
[{"id":1,"titulo":"probar en real","hecha":false,"creadaEn":"2026-09-29T17:45:08.805Z"}]

# La documentación interactiva, en el navegador: http://localhost:3000/docs
# El documento OpenAPI en JSON: http://localhost:3000/docs/json

$ kill -TERM <pid>
{"level":30,"signal":"SIGTERM","msg":"cerrando"}      # close-with-grace cierra con orden

En /docs tienes Swagger UI con todas las rutas, sus parámetros, sus schemas de entrada y salida y un botón para probarlas. No has escrito ni una línea de documentación: sale de los mismos schemas que validan y serializan.

Tests con node:test e inject()

app.inject() simula una petición HTTP completa (pasa por el router, los hooks, la validación y la serialización) sin abrir ningún socket. Los tests son rapidísimos y no chocan por puertos ocupados. Uso el test runner que trae Node.js (node:test), sin instalar Jest ni Vitest:

// test/tareas.test.ts: pruebas sin abrir ningún puerto, con inject()
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { construirApp } from '../src/app.js'

test('CRUD de tareas', async (t) => {
  const app = await construirApp()
  t.after(() => app.close()) // cierra la app al terminar (y ejecuta onClose)

  await t.test('crea una tarea', async () => {
    const res = await app.inject({ method: 'POST', url: '/tareas', payload: { titulo: 'Escribir el artículo' } })
    assert.equal(res.statusCode, 201)
    assert.equal(res.headers.location, '/tareas/1')
    const tarea = res.json()
    assert.equal(tarea.titulo, 'Escribir el artículo')
    assert.equal(tarea.hecha, false)          // valor por defecto del schema
  })

  await t.test('rechaza un body inválido', async () => {
    const res = await app.inject({ method: 'POST', url: '/tareas', payload: { titulo: '' } })
    assert.equal(res.statusCode, 400)
    assert.equal(res.json().error, 'datos_invalidos')
  })

  await t.test('marca la tarea como hecha', async () => {
    const res = await app.inject({ method: 'PATCH', url: '/tareas/1', payload: { hecha: true } })
    assert.equal(res.statusCode, 200)
    assert.equal(res.json().hecha, true)
  })

  await t.test('filtra por query string', async () => {
    await app.inject({ method: 'POST', url: '/tareas', payload: { titulo: 'Publicarlo' } })
    const res = await app.inject({ url: '/tareas?hecha=false' })
    assert.deepEqual(res.json().map((t: { titulo: string }) => t.titulo), ['Publicarlo'])
  })

  await t.test('404 con código de error propio', async () => {
    const res = await app.inject({ url: '/tareas/999' })
    assert.equal(res.statusCode, 404)
    assert.deepEqual(res.json(), { error: 'TAREA_NO_ENCONTRADA', mensaje: 'No existe la tarea 999' })
  })

  await t.test('borra', async () => {
    const res = await app.inject({ method: 'DELETE', url: '/tareas/1' })
    assert.equal(res.statusCode, 204)
    assert.equal((await app.inject({ url: '/tareas/1' })).statusCode, 404)
  })

  await t.test('genera el documento OpenAPI', async () => {
    await app.ready()
    const doc = app.swagger() as { paths: Record<string, unknown> }
    assert.ok(doc.paths['/tareas/{id}'])
  })
})
$ npm test
# Subtest: CRUD de tareas
    ok 1 - crea una tarea
    ok 2 - rechaza un body inválido
    ok 3 - marca la tarea como hecha
    ok 4 - filtra por query string
    ok 5 - 404 con código de error propio
    ok 6 - borra
    ok 7 - genera el documento OpenAPI
ok 1 - CRUD de tareas
# tests 8
# pass 8
# fail 0

Con base de datos: @fastify/postgres

Cambiar el almacén en memoria por PostgreSQL es cuestión de un plugin. @fastify/postgres crea un pool de conexiones de pg, lo expone como app.pg, lo cierra solo al apagar y trae un ayudante para transacciones. Este ejemplo lo probé contra un PostgreSQL 17 en Docker:

// postgres.js: el mismo CRUD, ahora con PostgreSQL de verdad
import Fastify from 'fastify'
import postgres from '@fastify/postgres'

const app = Fastify({ logger: false })

await app.register(postgres, {
  connectionString: process.env.DATABASE_URL // postgres://usuario:clave@host:5432/bd
})

// Crea la tabla al arrancar (en un proyecto real, usa migraciones)
app.addHook('onReady', async () => {
  await app.pg.query(`CREATE TABLE IF NOT EXISTS tareas (
    id SERIAL PRIMARY KEY,
    titulo TEXT NOT NULL,
    hecha BOOLEAN NOT NULL DEFAULT false,
    creada_en TIMESTAMPTZ NOT NULL DEFAULT now()
  )`)
})

app.get('/tareas', async () => {
  const { rows } = await app.pg.query('SELECT * FROM tareas ORDER BY id')
  return rows
})

app.post('/tareas', {
  schema: {
    body: {
      type: 'object',
      required: ['titulo'],
      properties: { titulo: { type: 'string', minLength: 1 } }
    }
  }
}, async (request, reply) => {
  // Siempre con parámetros ($1): nunca concatenes texto del usuario en el SQL
  const { rows } = await app.pg.query(
    'INSERT INTO tareas (titulo) VALUES ($1) RETURNING *',
    [request.body.titulo]
  )
  return reply.code(201).send(rows[0])
})

// Transacción: si algo falla dentro, se hace ROLLBACK automáticamente
app.post('/tareas/completar-todas', async () => {
  return app.pg.transact(async (client) => {
    const { rowCount } = await client.query('UPDATE tareas SET hecha = true WHERE NOT hecha')
    return { actualizadas: rowCount }
  })
})

await app.listen({ port: 3000, host: '0.0.0.0' })
$ curl -s -XPOST localhost:3000/tareas -H 'content-type: application/json' \
    -d "{\"titulo\":\"Robert'); DROP TABLE tareas;--\"}"
{"id":1,"titulo":"Robert'); DROP TABLE tareas;--","hecha":false,"creada_en":"2026-09-29T17:46:20.934Z"}
# La tabla sigue ahí: el texto se guardó como texto, gracias a $1

$ curl -s -XPOST localhost:3000/tareas/completar-todas
{"actualizadas":2}

Si prefieres un ORM o un query builder, Fastify no te ata a nada: Prisma, Drizzle o Kysely se integran con un plugin de 10 líneas que decore la instancia con el cliente y lo cierre en onClose.

PluginPara quéVersión probada
@fastify/corsCabeceras CORS11.3.0
@fastify/helmetCabeceras de seguridad (CSP, HSTS...)13.1.1
@fastify/rate-limitLímite de peticiones por IP o por clave11.2.0
@fastify/jwtFirmar y verificar tokens JWT10.2.2
@fastify/swagger + @fastify/swagger-uiOpenAPI generado desde tus schemas + interfaz web9.9.0 / 6.1.1
@fastify/postgresPool de pg compartido, con transacciones6.1.0
@fastify/errorCrear errores con código y status4.2.0
@fastify/type-provider-typeboxTipos de TypeScript desde los schemas6.1.0
@fastify/autoloadCargar plugins y rutas desde carpetas(lo trae el generador)
@fastify/static, @fastify/multipart, @fastify/cookie, @fastify/websocket, @fastify/compressFicheros estáticos, subidas, cookies, websockets, gzip/brotli—

Errores comunes

Mezclar async con reply.send() en un callback

Es el error número uno de quien viene de Express. En un handler async, Fastify toma lo que devuelves como respuesta. Si respondes más tarde desde un callback y no devuelves nada, Fastify envía una respuesta vacía inmediatamente, y cuando tu callback llama a send() ya es tarde:

// trampas.js: responder tarde desde un handler async
import Fastify from 'fastify'
const app = Fastify({ logger: { level: 'warn' } })

// MAL: async + respuesta en un callback, sin devolver nada
app.get('/mal', async (request, reply) => {
  setTimeout(() => reply.send({ tarde: true }), 10)
})

// BIEN: devolver reply indica "ya contestaré yo"
app.get('/bien', async (request, reply) => {
  setTimeout(() => reply.send({ tarde: true }), 10)
  return reply
})

// MAL: send y además return
app.get('/doble', async (request, reply) => {
  reply.send({ a: 1 })
  return { b: 2 }
})

for (const u of ['/mal', '/bien', '/doble']) {
  const r = await app.inject(u); console.log(u, r.statusCode, r.body)
}
/mal   200 ""                  ← respuesta vacía, y en el log:
        "Reply was already sent, did you forget to "return reply" in "/mal" (GET)?"
/bien  200 {"tarde":true}
/doble 200 {"a":1}             ← y el mismo aviso en el log

Regla: en funciones async, o devuelves el valor, o devuelves reply. Mejor aún, convierte el callback en promesa y usa await.

Olvidar fastify-plugin

Registras tu plugin de base de datos, decoras con db, y en las rutas app.db es undefined. Es la encapsulación: sin fastify-plugin, el decorador se quedó en el contexto hijo. Envuélvelo con fp().

Registrar sin esperar y usar demasiado pronto

app.register() no carga el plugin en ese instante: lo encola en avvio. Si justo después usas algo que ese plugin decora, puede no existir todavía. Usa await app.register(...) cuando necesites el resultado en la línea siguiente, o haz el trabajo dentro de otro plugin (que se cargará después) o en onReady.

Escuchar en localhost dentro de Docker

El clásico "Bad Gateway" en Coolify. Si no pasas host: '0.0.0.0', Fastify escucha en 127.0.0.1, que dentro del contenedor es solo el propio contenedor, y Traefik no puede llegar. Lo explico con más detalle en Coolify a fondo.

Mutar el objeto que devuelve el schema de respuesta y esperar verlo

Añades un campo nuevo a la respuesta, lo devuelves… y no sale. No es un bug: no está en el schema de response, así que fast-json-stringify lo ignora. Añádelo al schema.

Usar decorateRequest con un objeto

app.decorateRequest('datos', {}) lanza un error en Fastify 5: ese objeto se compartiría entre todas las peticiones (y entre usuarios). Usa null y asígnalo en un hook, o pasa un getter.

Despliegue: Docker y Coolify

Un Dockerfile multi-etapa: la primera compila TypeScript con todas las dependencias; la segunda solo lleva las de producción y el JavaScript compilado. Lo construí y lo arranqué en mi servidor: la imagen final pesa unos 195 MB y el health check pasa a healthy en segundos.

# --- Etapa 1: compilar TypeScript ---
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# --- Etapa 2: imagen final, solo dependencias de producción ---
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=3000
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD wget -qO- http://127.0.0.1:3000/salud || exit 1
CMD ["node", "dist/server.js"]
node_modules
dist
*.log
ej
test
docker build -t api-tareas .
docker run -d --name api-tareas -p 127.0.0.1:3000:3000 api-tareas
docker ps --format '{{.Names}}  {{.Status}}'
# api-tareas  Up 15 seconds (healthy)

En Coolify, crea una aplicación desde tu repositorio con el build pack Dockerfile, puerto 3000 y health check en /salud. Si quieres la API con su PostgreSQL en un solo recurso, usa Docker Compose con las variables mágicas de Coolify:

services:
  api:
    build: .
    environment:
      - SERVICE_FQDN_API_3000            # Coolify crea el dominio y lo enruta al 3000
      - NODE_ENV=production
      - DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@db:5432/tareas
      - JWT_SECRET=${SERVICE_PASSWORD_JWT}
    depends_on:
      - db
  db:
    image: postgres:17-alpine
    environment:
      - POSTGRES_USER=${SERVICE_USER_POSTGRES}
      - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
      - POSTGRES_DB=tareas
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

Checklist de producción:

  • host: '0.0.0.0' y el puerto desde process.env.PORT.
  • NODE_ENV=production, logs en JSON sin pino-pretty.
  • trustProxy activado si hay Traefik o Cloudflare delante.
  • Apagado ordenado con close-with-grace (Coolify manda SIGTERM al desplegar).
  • Un health check barato (/salud) y excluido de los logs.
  • Schemas de respuesta en todas las rutas públicas.
  • Manejador de errores que no filtre detalles en los 5xx.
  • Dependencias al día: Fastify saca parches de seguridad a menudo.

Ventajas e inconvenientes

Lo mejor

  • Validación y serialización integradas y basadas en un estándar. Un schema te da validación, tipos, documentación y protección contra fugas de datos.
  • La encapsulación: el sistema de plugins escala muy bien en proyectos grandes.
  • Logging y testing de primera, sin instalar nada.
  • Rendimiento muy alto con muy poco esfuerzo.
  • Ecosistema oficial cuidado, mantenido por el mismo equipo y con versiones alineadas.
  • TypeScript bien resuelto con los type providers.

Lo peor

  • Curva de aprendizaje mayor que Express: encapsulación, orden de registro, hooks async frente a callback… Al principio desconcierta.
  • Los middlewares de Express no funcionan directamente; existe @fastify/express/@fastify/middie para compatibilidad, pero lo ideal es usar el plugin nativo.
  • Solo Node.js (y compatibles como Bun, con matices). Si quieres desplegar en Cloudflare Workers o Deno Deploy, Hono encaja mejor.
  • Mucho JSON Schema: verboso si lo escribes a mano (TypeBox lo alivia mucho).
  • Actualizaciones frecuentes, muchas de seguridad: hay que estar atento.

Cuándo lo elijo

Para una API REST o JSON en Node.js que vaya a crecer, Fastify es hoy mi opción por defecto. Si necesito una estructura muy opinada con inyección de dependencias para un equipo grande, miro NestJS (que puede usar Fastify por debajo). Si el destino es el edge (Workers, Deno, Bun), Hono. Si es un prototipo de una tarde o mantengo algo que ya existe, Express sigue siendo perfectamente válido.

Sigue leyendo

Este artículo es parte de una serie sobre frameworks de backend en JavaScript, todos con el mismo nivel de detalle y código probado:

Y si vas a desplegarlo en tu propio servidor, Coolify a fondo cuenta cómo lo hago yo.