Fastify, the schema as the source of truth in an API
Fastify is a Node web framework in which a data description written as a JSON schema serves three purposes at once: it validates the input, builds the function that serialises the output, and becomes the OpenAPI documentation. The current version is 5.12.1, released on 18 August 2026, the licence is MIT, and the package pulls in fifteen production dependencies.
What Fastify actually does
The framework is a set of independent libraries joined by a common interface, and knowing that split helps a great deal when diagnosing problems.
Routing is handled by find-my-way, a prefix tree that matches a path without walking a list of regular expressions. Validation runs on AJV wired in through @fastify/ajv-compiler, which compiles every schema into a checking function once, at server start. Response serialisation is done by fast-json-stringify, which generates a specialised function from the response schema instead of calling JSON.stringify. Logging goes through pino. Plugin load order is driven by avvio. Tests without opening a network socket are enabled by light-my-request, exposed as the app.inject method.
That composition explains where the difference from Express comes from. In Express each of those things is a separate decision: validation is added as middleware, serialisation is the default JSON.stringify, and documentation lives in a separate YAML file that stops matching the code within a week. In Fastify all three come out of the same schema object attached to the route.
What Fastify does not do matters just as much. It has no data access layer, so queries are written through Prisma or any other driver. It does not render an interface, since that is Next.js territory. It does not generate a typed browser client from the server definition, which is what tRPC does. It imposes neither a directory structure nor dependency injection, unlike NestJS, which incidentally can use Fastify as its transport layer.
Version, licence and project health
The npm registry lists version 5.12.1 of the fastify package, published on 18 August 2026, and the total number of releases has passed three hundred and twenty. Minor releases arrive regularly, which at major version five means a project in maintenance and gradual improvement rather than rewriting.
The licence is an exemplary case and there is nothing to untangle here. The LICENSE file in the repository carries the MIT text with the notice "Copyright (c) 2016-present The Fastify team". The license field in the npm registry reads MIT. The published archive fastify-5.12.1.tgz contains a LICENSE file next to the code, so even an install with no access to GitHub puts the full licence text on disk. Three sources, one answer. The official plugins from the @fastify namespace checked while writing this text, meaning cors, helmet, rate-limit, swagger, swagger-ui, under-pressure and autoload, also declare MIT, as do fastify-plugin and fastify-type-provider-zod.
One detail in the metadata deserves attention, because it misleads people. The package.json of version 5.12.1 contains no engines field. A package manager will therefore not warn you when installing on an outdated Node, and the problem only surfaces at runtime. You have to police the runtime version yourself, for instance through .nvmrc plus a check in the continuous integration pipeline.
The maintenance model is stated openly. The published package contains GOVERNANCE.md, PROJECT_CHARTER.md, SECURITY.md and SPONSORS.md files, so the project has written rules for making decisions and a path for reporting vulnerabilities. There is no paid variant and no commercial support contract. That is a typical arrangement for open source software and carries the usual risk: the pace of fixes depends on volunteer availability rather than on an obligation you can enforce.
Installation and a first server
Getting into a project takes a few minutes, and most configuration decisions are made when the instance is created.
# the framework core
npm install fastify
# the helper for plugins shared between contexts
npm install fastify-plugin
# common production add-ons
npm install @fastify/cors @fastify/helmet @fastify/rate-limit
# generating and exposing OpenAPI documentation
npm install @fastify/swagger @fastify/swagger-uiThe instance is created by a factory function, and the options passed at that point apply to the whole server.
import Fastify from 'fastify'
const app = Fastify({
logger: { level: 'info' },
bodyLimit: 1048576,
requestIdHeader: 'x-request-id',
disableRequestLogging: false,
caseSensitive: true,
ignoreTrailingSlash: false,
maxParamLength: 200,
pluginTimeout: 10000,
connectionTimeout: 15000,
keepAliveTimeout: 72000,
forceCloseConnections: 'idle',
trustProxy: true,
ajv: {
customOptions: {
removeAdditional: 'all',
coerceTypes: 'array',
useDefaults: true,
allErrors: false
}
}
})
app.get('/health', async () => ({ status: 'ok' }))
await app.listen({ port: 3000, host: '0.0.0.0' })Several of those fields carry non-obvious consequences. Setting trustProxy enables reading the x-forwarded-for header, which is required behind a reverse proxy and dangerous when the server faces the network directly. The value 'idle' for forceCloseConnections makes app.close() shut down idle keep-alive connections, without which stopping the process can hang until keepAliveTimeout expires. The pluginTimeout field caps how long a single plugin may take to load and is a frequent cause of the slow-start message when a plugin waits on a database connection.
The removeAdditional, coerceTypes and useDefaults options are AJV settings passed through ajv.customOptions. The first strips fields absent from the schema, the second converts types, so a number arriving in the query string stops being a string, and the third fills in defaults declared in the schema. The ajv-formats package is a dependency of @fastify/ajv-compiler, so the format keyword works without an extra install.
The schema as the source of truth
This is the real reason people choose Fastify. One object describes the route contract, and three distinct runtime behaviours follow from that description.
app.post('/invoices', {
schema: {
tags: ['invoices'],
body: {
type: 'object',
required: ['customerId', 'amount'],
additionalProperties: false,
properties: {
customerId: { type: 'string', format: 'uuid' },
amount: { type: 'integer', minimum: 1 },
currency: { type: 'string', enum: ['PLN', 'EUR'], default: 'PLN' },
note: { type: 'string', maxLength: 500 }
}
},
querystring: {
type: 'object',
properties: {
dryRun: { type: 'boolean', default: false }
}
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
amount: { type: 'integer' },
currency: { type: 'string' },
createdAt: { type: 'string' }
}
},
422: {
type: 'object',
properties: {
error: { type: 'string' },
detail: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const invoice = await app.db.invoice.create({ data: request.body })
return reply.code(201).send(invoice)
})The body section rejects a request with a missing field or a negative amount before the handler function runs at all. The querystring section turns the string "true" into a boolean. The response section is the one that surprises people most often, because it does not validate the output. Fastify compiles a serialising function from it, and anything absent from the schema simply does not reach the response. That works as a guard against leaking fields you do not want exposed, and simultaneously as a trap: a taxId field added in the database will not appear in the JSON until you add it to the schema, and no error tells you so.
Repeated schemas are registered once and referenced through $ref, which shortens route definitions and keeps the contract in one place.
app.addSchema({
$id: 'money',
type: 'object',
properties: {
amount: { type: 'integer' },
currency: { type: 'string', enum: ['PLN', 'EUR'] }
}
})
app.get('/invoices/:id/total', {
schema: {
params: {
type: 'object',
properties: { id: { type: 'string' } }
},
response: { 200: { $ref: 'money#' } }
}
}, async (request) => calculateTotal(request.params.id))Writing JSON schemas by hand is tiring, especially when the same shapes already exist as TypeScript types. The answer is a type provider, which swaps out the validator and serialiser compilers and closes the loop on type inference for request.body along the way.
import { z } from 'zod'
import {
validatorCompiler,
serializerCompiler,
type ZodTypeProvider
} from 'fastify-type-provider-zod'
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
const typed = app.withTypeProvider<ZodTypeProvider>()
typed.post('/customers', {
schema: {
body: z.object({
email: z.email(),
plan: z.enum(['free', 'team'])
}),
response: {
201: z.object({ id: z.string(), email: z.email() })
}
}
}, async (request, reply) => {
const customer = await createCustomer(request.body)
return reply.code(201).send(customer)
})The fastify-type-provider-zod package at version 7.0.0 requires Zod at 4.1.5 or newer and Fastify from the 5.5 line up, and it also lists @fastify/swagger as a peer dependency. That follows directly from the intent: the same Zod schema should produce both the validation and the documentation entry.
The third role of the schema is OpenAPI. The @fastify/swagger plugin collects the schemas of every registered route and builds a document from them, while @fastify/swagger-ui serves it at an address of your choosing.
await app.register(import('@fastify/swagger'), {
openapi: {
info: { title: 'Billing API', version: '1.4.0' },
servers: [{ url: 'https://api.example.com' }]
},
hideUntagged: true
})
await app.register(import('@fastify/swagger-ui'), {
routePrefix: '/docs',
uiConfig: { docExpansion: 'list', deepLinking: true },
staticCSP: true
})The hideUntagged option skips routes without a tags field, a convenient way of keeping health checks and internal routes out of the public documentation. The plugin runs in dynamic mode, meaning it reads server state rather than a separate file, so the documentation cannot drift from the code as long as the schemas are complete.
Plugin encapsulation and its price
The second pillar of Fastify is less obvious, and it accounts for most beginner questions. Calling app.register adds nothing to a shared space; it creates a new child context instead. Decorators, hooks and schemas registered in that context are visible to its children, but not to its siblings or its parent.
import fp from 'fastify-plugin'
import type { FastifyInstance } from 'fastify'
import { PrismaClient } from '@prisma/client'
async function databasePlugin(app: FastifyInstance) {
const prisma = new PrismaClient()
await prisma.$connect()
app.decorate('db', prisma)
app.addHook('onClose', async () => {
await prisma.$disconnect()
})
}
export default fp(databasePlugin, {
name: 'db',
fastify: '5.x'
})The fp function from the fastify-plugin package wraps a plugin so that its contents escape the context and become available to siblings. The metadata it accepts is concrete: name sets the name visible in the plugin tree and in error messages, fastify declares a supported framework version range, dependencies lists names of plugins that must load first, decorators states the decorators required from the surroundings, and encapsulate lets you invert the behaviour and keep encapsulation despite using fp.
The practical rule is simple. A plugin providing a shared resource, meaning a database connection, a cache client or a token verifier, goes through fp. A plugin holding routes and hooks specific to one area needs no fp and is better left closed.
async function invoiceRoutes(app: FastifyInstance) {
app.decorateRequest('actor', null)
app.addHook('preHandler', async (request) => {
request.actor = await resolveActor(request.headers.authorization)
})
app.get('/invoices', async (request) => app.db.invoice.findMany())
}
app.register(databasePlugin)
app.register(invoiceRoutes, { prefix: '/api/v1' })
app.register(publicRoutes, { prefix: '/public' })
await app.ready()
console.log(app.printRoutes())
console.log(app.printPlugins())The preHandler hook registered inside invoiceRoutes applies only within that context. Routes from publicRoutes will never see it, even though both plugins hang off the same parent. The same principle governs authentication, and this is the most sensible way of separating the protected part from the public one: no array of exempt paths, no prefix checking inside a middleware.
Now the cost, because encapsulation is a drawback as much as a benefit. The message about a missing decorator arrives only at runtime and says merely that a property is undefined, not that you forgot fp. Registration order matters, so a plugin reading a decorator added by another plugin has to declare that through dependencies, otherwise the result depends on ordering within the file. The printRoutes and printPlugins methods called after await app.ready() are the primary diagnostic tools here and show the route tree and the context tree respectively. Somebody meeting this model for the first time will lose a day or two on it, and no amount of reading the documentation shortens that.
The second cost is the ecosystem. The list of official @fastify plugins is decent and covers authentication, rate limiting, file handling, sessions, static files and security headers, but it remains an order of magnitude smaller than the body of middleware written for Express over the years. For an unusual requirement, say integrating with an ageing identity provider, the odds of finding a ready package are lower. Bridges exist, @fastify/middie at version 9.3.3 for plain middleware and @fastify/express at version 4.0.7 for a full compatibility layer, but each of them gives back part of the performance gain and introduces request and response objects shaped like Express.
The request lifecycle and hooks
Hooks are attachment points in request processing and their order is fixed. After a route matches, Fastify runs onRequest, preParsing, preValidation and preHandler in turn, then the handler function, and after it preSerialization, onSend and onResponse. Outside that path sit onError, onTimeout and onRequestAbort, the last one firing when a client closes the connection before receiving a response.
app.addHook('onRequest', async (request) => {
request.startedAt = process.hrtime.bigint()
})
app.addHook('onSend', async (request, reply, payload) => {
const elapsed = Number(process.hrtime.bigint() - request.startedAt) / 1e6
reply.header('server-timing', `app;dur=${elapsed.toFixed(1)}`)
return payload
})
app.setErrorHandler((error, request, reply) => {
if (error.code === 'FST_ERR_VALIDATION') {
return reply.code(422).send({ error: 'validation_failed', detail: error.message })
}
request.log.error({ err: error }, 'unhandled error')
return reply.code(500).send({ error: 'internal' })
})
app.setNotFoundHandler(async (request, reply) => {
return reply.code(404).send({ error: 'not_found', path: request.url })
})Choosing a hook follows from what is already available. In onRequest the body has not been read yet, so checking a header token fits perfectly there, and rejecting the request saves the parser some work. In preValidation the body exists but has not passed validation, which is the place for normalising data from an external system. In preHandler you hold validated data, and that is the natural spot for permission checks.
Validation errors carry the code FST_ERR_VALIDATION and by default end in a 400 response. If you prefer handling them inside the route rather than in a global handler, set attachValidation: true in the route options and Fastify will write the error into request.validationError instead of throwing. Application hooks are a separate group and run outside the request cycle: onReady before listening begins, onListen once it has, onRoute for every route registered, onRegister for every new context, and preClose plus onClose when the server shuts down. Request hooks are subject to encapsulation as well, which means the same code placed a level higher or lower in the plugin tree produces a different scope.
Fastify against the alternatives
| Feature | Fastify | Express | Hono | Elysia | NestJS |
|---|---|---|---|---|---|
| Built-in validation | yes, JSON schema | none, via middleware | via a validator package | yes, own type system | yes, via decorators and class-validator |
| Serialisation from schema | yes, compiled function | none | none | partial | none |
| OpenAPI documentation | from the same schema | from a separate file | from an extra package | built-in module | from decorators |
| Extension model | plugins with encapsulation | global middleware | chained middleware | plugins | modules and injection |
| Runtime | Node HTTP server | Node HTTP server | many, based on Fetch API | mainly Bun | Node, swappable transport |
| Current version | 5.12.1 | 5.2.1 | 4.13.3 | 1.4.29 | 11.2.1 |
| Licence | MIT | MIT | MIT | MIT | MIT |
The choice comes down to a few questions. If you are building an API whose contract should be enforced by machine and published as OpenAPI, Fastify delivers that with the least effort. If the target is an edge function or a worker in a Fetch API environment, reach for Hono, because Fastify stands on the Node HTTP module and will not run there. If you work in Bun and want type inference without a separate provider, consider Elysia, keeping in mind that its community is markedly smaller. If the only client of the API is your own TypeScript front end, tRPC removes the contract problem entirely, but locks you into one language on both sides. And if the team is large and needs an imposed structure, NestJS supplies it directly and can run on a Fastify adapter, combining both approaches.
Express remains a sensible choice in two situations: when the project already sits on it with no performance problem, and when you depend on middleware with no equivalent in the Fastify ecosystem. Migrating from Express in practice always requires rewriting the middleware layer, because the request and response interfaces differ.
Common mistakes
The first is surprise at disappearing fields. A response schema does not check the response, it builds it, so a field absent from the schema never reaches the result and no warning appears. Every time you add a field to the model, add it to the schema too.
The second is a decorator set inside a plain plugin rather than one wrapped by fp. A database connection attached through app.decorate inside a plain function exists only in that context, and siblings will see an undefined value. The symptom looks like a load ordering bug while the cause is encapsulation.
The third is using return and reply.send together in an async function. Returning a value from an async function sends the response by itself, so an earlier reply.send produces an attempt to send it twice. Pick one convention and hold to it across the project.
The fourth is numbers arriving from the query string as strings. Without a querystring schema or without coerceTypes in the AJV options, the parameter ?page=2 stays a string and a comparison against a number yields false. That is the most frequent cause of odd pagination.
The fifth is registering routes after calling listen. Fastify seals the plugin tree at start, so a route added later is rejected. All configuration has to run beforehand, and if you need asynchronous preparation, use await app.ready().
The sixth is trustProxy enabled with no proxy in front of the server. In that arrangement a client can put any address into the x-forwarded-for header and bypass rate limiting keyed on the source address.
The seventh is relying on the package manager for the Node version. The fastify package at version 5.12.1 declares no engines field, so installing on an old environment passes without a word and the server only falls over at startup.
FAQ
Is Fastify genuinely faster than Express?
The difference comes from three specific mechanisms rather than general optimisation. Routing walks a prefix tree instead of a match list. Validation and serialisation are compiled once at startup into specialised functions, so there is no reflection over the schema during a request. The size of the gain depends on the shape of the response and on how much time the database takes. Measure it under your own load before declaring the framework the bottleneck.
Do I have to write JSON schemas by hand?
No. A type provider swaps the validator and serialiser compilers, letting you write schemas in Zod while Fastify consumes them exactly as it would JSON schemas. The fastify-type-provider-zod package at version 7.0.0 requires Zod 4.1.5 or newer and Fastify from the 5.5 line up. The cost is an extra dependency and the fact that the OpenAPI schema comes from a conversion rather than directly.
Will Fastify run at the edge or on Bun?
Fastify builds on the Node HTTP module rather than the Web Fetch API, so edge environments based on the Fetch standard are not its territory and Hono is the tool reached for there. Bun offers a Node API compatibility layer, but that is a different scenario from a framework written for that runtime from the start, as Elysia is.
Can middleware be carried over from Express?
Partly. The @fastify/middie plugin at version 9.3.3 lets you use middleware functions with the three-argument signature, and @fastify/express at version 4.0.7 provides a broader compatibility layer. Both add overhead and introduce Express-shaped objects, so treat them as a migration stage rather than an end state.
Is Fastify paid?
No. The whole project is under the MIT licence, confirmed by the LICENSE file in the repository, by the license field in the npm registry, and by the licence file inside the published archive. There is no commercial variant and no paid support, and development is funded by the sponsors listed in SPONSORS.md.
Documentation lives on the Fastify site, the list of official plugins in the ecosystem section, and the source code in the GitHub repository.