Cómo Construir una API REST con Fastify
Crea una API REST con Fastify v5 y Prisma, con validación JSON Schema, plugins, decoradores, hooks y tipos TypeBox para CRUD de posts.
Fastify es un framework web para Node.js de alto rendimiento y bajo overhead cuya característica definitoria es la validación y serialización basada en JSON Schema: se declara la forma de cada solicitud y respuesta, y Fastify compila esos schemas en funciones de alto rendimiento durante el arranque. Según sus mantenedores, Fastify es uno de los frameworks web más rápidos del mercado, capaz de servir más de 76 mil solicitudes por segundo dependiendo de la complejidad del código. Este tutorial construye una API REST CRUD completa para un recurso posts sobre Fastify v5 con Prisma como capa de datos, y se centra en los cuatro primitivos que diferencian a Fastify de Express: plugins encapsulados, decoradores, validación/serialización basada en schemas y hooks de ciclo de vida.
Puntos Clave
- Fastify v5 requiere Node.js v20 o superior; v4 llegó al final de su vida útil el 30 de junio de 2025, y v3 y versiones anteriores no tienen mantenimiento, por lo que las nuevas APIs deben comenzar en la línea v5.x.
- Un schema de
responseen Fastify cumple una doble función: acelera la serialización al compilar un serializador dedicado, y elimina cualquier propiedad no declarada en el schema, evitando que campos internos se filtren en las respuestas. - A partir de v5 el shorthand de schemas ha sido eliminado: cada schema de
querystring,params,bodyyresponsedebe ser un JSON Schema completo que incluya una propiedadtype. - Comparte un cliente de base de datos entre rutas envolviendo el plugin en
fastify-pluginpara romper deliberadamente la encapsulación, y luego adjunta el cliente confastify.decorate('prisma', client). - Con el type provider de TypeBox se declara cada schema una sola vez y Fastify infiere los tipos de solicitud y respuesta a partir de él, eliminando la divergencia entre las reglas de validación y los tipos de TypeScript.
Por qué Fastify: los cuatro primitivos que Express no tiene
Es posible escribir los mismos endpoints CRUD en Express. Lo que no se puede reproducir fácilmente es el modelo de Fastify, construido sobre cuatro primitivos: plugins encapsulados, decoradores, validación y serialización basada en schemas, y hooks de ciclo de vida. Los mantenedores señalan que Fastify es totalmente extensible a través de sus hooks, plugins y decoradores. Estos no son añadidos de conveniencia sobre un router; son la arquitectura en sí misma.
| Aspecto | Express (típico) | Fastify (idiomático) |
|---|---|---|
| Validación de entrada | Manual o mediante middleware (express-validator) | JSON Schema declarado en la ruta |
| Serialización de respuesta | JSON.stringify | fast-json-stringify compilado a partir de un schema de respuesta |
| Compartir recursos | Middleware global sobre un req compartido | Plugins encapsulados + decoradores |
| Ciclo de vida de la solicitud | Cadena lineal de middlewares | Hooks tipados (onRequest, preHandler, onError, …) |
La afirmación de rendimiento es mecánica, no de marketing. Aunque no es obligatorio, Fastify recomienda utilizar JSON Schema para validar rutas y serializar salidas; internamente, Fastify compila el schema en una función de alto rendimiento. Un schema de respuesta se convierte en un serializador especializado durante el arranque, de modo que la generación de JSON evita la ruta genérica de JSON.stringify. Trata las cifras oficiales por lo que son: estos benchmarks se realizan con una prueba sintética de “hello world” que busca evaluar el overhead del framework — realiza benchmarks de tu propia aplicación antes de citar números.
Configuración del proyecto y un servidor mínimo en v5
Discover how at OpenReplay.com.
Comienza con ESM, ya que tanto Fastify como Prisma v7 son ESM-first. Fastify v5 solo soporta Node.js v20 o superior; si estás usando una versión anterior de Node.js, necesitarás actualizarla para poder utilizar Fastify v5.
mkdir fastify-posts-api && cd fastify-posts-api
npm init -y
npm pkg set type="module"
npm i fastify@5
La versión estable actual es 5.9.0, publicada el 28 de junio de 2026. Crea server.js:
import Fastify from 'fastify'
const app = Fastify({ logger: true })
app.get('/health', async () => ({ status: 'ok' }))
try {
await app.listen({ port: 3000 })
} catch (err) {
app.log.error(err)
process.exit(1)
}
Observa la llamada a listen en forma de objeto. La firma con argumentos variables del método .listen() ha sido eliminada en v5, por lo que ya no es posible llamar a .listen() con un número variable de argumentos — siempre se debe pasar un objeto de opciones como { port: 3000 }. Ejecútalo con node --watch server.js y accede a http://localhost:3000/health.
Enrutamiento a la manera de Fastify: plugins y encapsulación
En Fastify, las rutas viven dentro de plugins, y cada plugin se ejecuta en su propio contexto de encapsulación. Un decorador, hook o ruta registrado dentro de un plugin es visible para ese plugin y sus hijos, pero invisible para sus hermanos — lo opuesto al middleware de Express, donde todo comparte un único objeto de solicitud global. La referencia de Plugins documenta este modelo de alcance.
Un plugin es simplemente una función async que recibe la instancia encapsulada:
// routes/posts.js
export default async function postsRoutes(app, opts) {
app.get('/', async (request, reply) => {
return [] // el handler de listado se implementa más adelante
})
}
Regístralo bajo un prefijo en server.js:
import postsRoutes from './routes/posts.js'
app.register(postsRoutes, { prefix: '/api/posts' })
Mantén las funciones de plugin consistentemente como async. La guía de migración a v5 es explícita en que todas las deprecaciones de v4 han sido eliminadas y ya no funcionarán tras la actualización, y mezclar los estilos de callback y promesa dentro de un mismo plugin — tolerado en v4 — ya no está permitido. Elige async/await y mantente en ese estilo.
Validación y serialización con schemas
Adjunta un objeto schema a cualquier ruta y Fastify validará el body, params, querystring y headers entrantes, y luego serializará el payload de salida contra el schema de response. Esta es la característica distintiva del framework, documentada en Validation and Serialization.
Una regla de v5 aplica a todos los schemas que escribas: el shorthand ha desaparecido. A partir de v5 debes proporcionar un JSON Schema completo para querystring, params, body y response, incluyendo la propiedad type en cada uno, o la ruta no validará — la guía de migración a v5 registra la eliminación de la opción jsonShortHand.
Define los schemas para el recurso posts:
// schemas/posts.js
export const postResponse = {
type: 'object',
properties: {
id: { type: 'string' },
title: { type: 'string' },
content: { type: 'string' },
published: { type: 'boolean' },
createdAt: { type: 'string' }
}
}
export const createPostBody = {
type: 'object',
required: ['title', 'content'],
properties: {
title: { type: 'string', minLength: 1 },
content: { type: 'string', minLength: 1 },
published: { type: 'boolean', default: false }
}
}
export const idParams = {
type: 'object',
required: ['id'],
properties: { id: { type: 'string' } }
}
El schema de response es la parte que la mayoría de los tutoriales omiten, y cumple su función de dos maneras. Se compila en un serializador dedicado más rápido que la serialización genérica, y actúa como lista de permitidos: cualquier propiedad que devuelva tu handler y que no esté declarada en el schema se descarta antes de llegar al cliente. Un passwordHash o un userId interno que se cuele en el resultado de una consulta nunca abandona el servidor, porque el serializador simplemente no sabe cómo emitirlo.
La ruta con TypeScript y TypeBox
Si estás en TypeScript, la fricción radica en mantener sincronizados los schemas de validación y las definiciones de tipos. El type provider de TypeBox los colapsa en una única declaración. Instala typebox como dependencia par requerida y @fastify/type-provider-typebox:
npm i typebox @fastify/type-provider-typebox
Registra el provider y declara cada schema una sola vez con Type:
import Fastify from 'fastify'
import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>()
app.post('/api/posts', {
schema: {
body: Type.Object({
title: Type.String({ minLength: 1 }),
content: Type.String({ minLength: 1 })
})
}
}, async (request) => {
// request.body tiene tipo { title: string; content: string }
const { title, content } = request.body
return { title, content }
})
Se importa Type y TypeBoxTypeProvider desde @fastify/type-provider-typebox y se llama a Fastify().withTypeProvider<TypeBoxTypeProvider>(); los tipos de los campos del body se infieren automáticamente. Utiliza el paquete con scope — el import de @sinclair/typebox que aparece en guías antiguas corresponde al nombre anterior a v5. Un detalle importante de v5: la guía de migración indica que los type providers han sido divididos en dos tipos separados: ValidatorSchema y SerializerSchema, por lo que debes actualizar el paquete del provider junto con Fastify. Ten en cuenta también que los tipos del provider no se propagan globalmente; en uso encapsulado, se puede reasignar el contexto para usar uno o más providers, lo que significa que debes redeclarar el provider en cada plugin que lo necesite.
La capa de base de datos como plugin decorador
Conecta la base de datos a la instancia de Fastify mediante un decorador, expuesto a través de un plugin que rompe deliberadamente la encapsulación. Para compartir un único recurso — un cliente de base de datos — entre todas las rutas, envuelve el plugin en fastify-plugin para que la decoración escape de su propio scope, y luego adjúntalo con fastify.decorate('prisma', client). La referencia de Decorators documenta este patrón.
Este tutorial utiliza Prisma (v7, actualmente 7.8.x), que no depende de Rust y es ESM-only — una combinación limpia para la configuración ESM anterior. Instálalo, define el schema y genera el cliente. Prisma 7 también requiere un driver adapter para crear el cliente, por lo que debes instalar el adaptador de SQLite junto con los paquetes principales:
npm i @prisma/client@7 @prisma/adapter-better-sqlite3
npm i -D prisma@7
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Post {
id String @id @default(uuid())
title String
content String
published Boolean @default(false)
createdAt DateTime @default(now())
}
Prisma 7 configura su CLI a través de un archivo prisma.config.ts y genera el cliente en la ruta output definida anteriormente; ejecuta npx prisma migrate dev para crear la base de datos y el cliente. Luego expónlo como decorador. Ten en cuenta que en Prisma 7 la llamada directa a new PrismaClient() ha sido eliminada — debes construir el cliente con un driver adapter:
// plugins/prisma.js
import fp from 'fastify-plugin'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { PrismaClient } from '../generated/prisma/client.js'
export default fp(async (app) => {
const adapter = new PrismaBetterSqlite3({ url: 'file:./dev.db' })
const prisma = new PrismaClient({ adapter })
await prisma.$connect()
app.decorate('prisma', prisma)
app.addHook('onClose', async (instance) => {
await instance.prisma.$disconnect()
})
})
Dado que el plugin está envuelto en fp, app.prisma es ahora accesible desde cualquier ruta de la aplicación.
¿Prefieres Postgres con TypeORM? El patrón de decorador es idéntico: conecta el DataSource y luego app.decorate('db', dataSource). Solo cambia la capa de schema.
Handlers CRUD con códigos de estado correctos
Implementa las cinco operaciones como rutas dentro del plugin de posts, cada una con su schema correspondiente. Devuelve el código de estado correcto para cada verbo:
201con el recurso creado en POST;200en lecturas;204sin cuerpo en DELETE; y404cuando una búsqueda no encuentra resultados.
Fastify establece el Content-Type y serializa contra tu schema de respuesta automáticamente — consulta la Reply API.
// routes/posts.js
import { postResponse, createPostBody, idParams } from '../schemas/posts.js'
export default async function postsRoutes(app) {
// CREATE
app.post('/', {
schema: { body: createPostBody, response: { 201: postResponse } }
}, async (request, reply) => {
const post = await app.prisma.post.create({ data: request.body })
return reply.code(201).send(post)
})
// READ all
app.get('/', {
schema: { response: { 200: { type: 'array', items: postResponse } } }
}, async () => {
return app.prisma.post.findMany({ orderBy: { createdAt: 'desc' } })
})
// READ one
app.get('/:id', {
schema: { params: idParams, response: { 200: postResponse } }
}, async (request, reply) => {
const post = await app.prisma.post.findUnique({ where: { id: request.params.id } })
if (!post) return reply.code(404).send({ message: 'Post not found' })
return post
})
// UPDATE
app.put('/:id', {
schema: { params: idParams, body: createPostBody, response: { 200: postResponse } }
}, async (request, reply) => {
const exists = await app.prisma.post.findUnique({ where: { id: request.params.id } })
if (!exists) return reply.code(404).send({ message: 'Post not found' })
return app.prisma.post.update({ where: { id: request.params.id }, data: request.body })
})
// DELETE
app.delete('/:id', {
schema: { params: idParams }
}, async (request, reply) => {
const exists = await app.prisma.post.findUnique({ where: { id: request.params.id } })
if (!exists) return reply.code(404).send({ message: 'Post not found' })
await app.prisma.post.delete({ where: { id: request.params.id } })
return reply.code(204).send()
})
}
Un detalle de v5 que vale la pena señalar en DELETE: en v4, Fastify permitía solicitudes DELETE con una cabecera Content-Type: application/json y un cuerpo vacío; esto ya no está permitido en v5. No envíes la cabecera Content-Type cuando no haya payload.
Manejo de errores, hooks y preparación para producción
Centraliza el manejo de errores con setErrorHandler e intercepta el ciclo de vida de la solicitud con hooks — los dos puntos de extensión que reemplazan la cadena de middlewares de Express. La referencia de Hooks lista el flujo de la solicitud en orden: onRequest → preParsing → preValidation → preHandler → handler → preSerialization → onSend → onResponse, con onError disparándose cuando algo lanza una excepción.
class NotFoundError extends Error {
constructor (message) { super(message); this.statusCode = 404 }
}
app.setErrorHandler((error, request, reply) => {
request.log.error(error)
const status = error.statusCode ?? 500
reply.code(status).send({
error: error.name,
message: status === 500 ? 'Internal Server Error' : error.message
})
})
// Ejemplo de hook preHandler: intercepta una ruta antes de que se ejecute el handler
app.addHook('preHandler', async (request) => {
request.log.info({ url: request.url }, 'incoming request')
})
Un fallo de validación cortocircuita el flujo antes de que tu handler se ejecute, devolviendo un 400 automáticamente — una razón más por la que los schemas reducen el código de los handlers. Para medir tiempos dentro de un hook, usa reply.elapsedTime; el método reply.getResponseTime() ha sido eliminado en v5, y debes usar reply.elapsedTime en su lugar.
A partir de aquí, la lista de verificación para producción consiste en instalar plugins, cada uno un paquete @fastify/* con scope que se registra de la misma manera que tu plugin de rutas:
@fastify/jwtpara autenticación;@fastify/swaggerpara generar documentación OpenAPI directamente desde tus schemas de rutas;@fastify/rate-limitpara throttling; y@fastify/corspara acceso cross-origin.
Para flujos de autenticación más complejos y despliegue, consulta guías dedicadas — esos son proyectos aparte. Si quieres ver cómo se comportan estos endpoints bajo tráfico real, combina la API con monitoreo de errores para Node.js en el frontend que la consume.
Ahora tienes una API CRUD con Fastify v5 funcional donde los schemas validan la entrada, serializan la salida y se autodocumentan, y donde la base de datos se comparte a través de un decorador en lugar de una importación global. El siguiente paso concreto: agrega @fastify/swagger, apúntalo a los schemas que ya escribiste, y observa cómo la especificación OpenAPI se genera sola — prueba de que en Fastify el schema es la fuente de verdad, no una reflexión tardía.
Preguntas Frecuentes
¿Cuál es la diferencia entre fastify.register y fastify.decorate?
El método register monta un plugin en su propio contexto de encapsulación, de modo que las rutas, hooks y decoradores añadidos dentro de él permanecen con scope limitado a ese plugin y sus hijos. El método decorate adjunta una propiedad o método reutilizable directamente sobre la instancia de Fastify, la solicitud o la respuesta. Normalmente se usa decorate para exponer recursos compartidos como un cliente de base de datos, y se envuelve ese plugin en fastify-plugin para que la decoración escape de la encapsulación y quede disponible en toda la aplicación.
¿Es Fastify más rápido que Express, y en qué medida?
El propio benchmark de Fastify reporta servir más de 76 mil solicitudes por segundo, pero los mantenedores advierten que se trata de una prueba sintética de 'hello world' que mide el overhead del framework, no el rendimiento en el mundo real. La ventaja mecánica es concreta: Fastify compila los JSON Schemas en funciones especializadas de validación y serialización durante el arranque, de modo que producir una respuesta evita el overhead genérico de JSON.stringify. Realiza siempre benchmarks de tu propia aplicación antes de citar cualquier multiplicador de velocidad.
¿Por qué falla la validación del schema de mi ruta en Fastify v5 si funcionaba en v4?
A partir de v5 el shorthand de schemas y la opción jsonShortHand han sido eliminados, por lo que cada schema de querystring, params, body y response debe ser un JSON Schema completo que incluya explícitamente una propiedad type. Un schema que solo declara propiedades sin un type de nivel superior ya no validará. Agrega type 'object' a cada objeto de schema. La guía de migración a v5 documenta esto como un cambio que rompe la compatibilidad con el comportamiento del shorthand de v4.
¿Puedo usar Fastify con TypeScript sin escribir los tipos dos veces?
Sí. Instala typebox como dependencia par junto con el paquete con scope @fastify/type-provider-typebox, luego llama a Fastify().withTypeProvider con el genérico TypeBoxTypeProvider. Declaras cada schema una sola vez usando Type, y Fastify infiere los tipos de solicitud y respuesta a partir de él, eliminando la divergencia entre las reglas de validación y las interfaces de TypeScript. Importa Type desde @fastify/type-provider-typebox, no desde el nombre heredado @sinclair/typebox que aparece en guías antiguas, y redeclara el provider en cada plugin que lo necesite.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k