Which of Fastify's many OpenAPI plugins are the best?
Fastify is a fantastic, popular, low-overhead Node.js framework, but how does the support for OpenAPI stack up?
Fastify took the JavaScript community by storm in 2016, offering a solid alternative to things like Express which were starting to feel a bit rough around the edges in a world where demands for APIs were growing, and HTTP was evolving rapidly.
One of the core features is JSON Schema validated routing, so several times over the last decade of wanting to find frameworks with good support for OpenAPI I have wandered over, and been let down by what was on offer.
There are three main ways API frameworks can integrate with OpenAPI.
- Annotations, Comments, or Decorators - Old school code-first approach of sprinkling some extra syntax around your code and hope that proximity leads to accuracy, which it generally doesn't.
- OpenAPI-aware Frameworks - The framework is creating OpenAPI simply from the actual bare bones of the code, so when you register a
app.post('/foo/{id}')and all the rest of the validation and logic, it will create that OpenAPI for you. The modern way to handle code-first. - OpenAPI Middlewares - The secret power of design-first is being able to reference that
openapi.yamlin a server-side validation middleware that uses the OpenAPI to provide powerful request validation (and often response contract testing) to reduce you writing all that code a second and third time.
Which of these approaches does Fastify offer between official and community plugins?
We're going to look at the following:
@fastify/swaggerfastify-openapi-glueeropple/fastify-openapi3- Something I just slapped together in five minutes using
openapi-data-validator.js.
Package 1: @fastify/swagger
The official plugin for Fastify, so the first place many will look.
A Fastify plugin for serving Swagger (OpenAPI v2) or OpenAPI v3 schemas, which are automatically generated from your route schemas, or an existing Swagger/OpenAPI schema.
The official @fastify/swagger plugin starts off showing its age just in the name. Swagger is the long dead name of OpenAPI, now only living on as a trademark for an ageing suite of SmartBear products, and generally abandoned by every modern tool in the OpenAPI ecosystem.
The tool mentions supporting "v2 or v3", and usually that means it does not support v3.1 or v3.2. Thankfully after some digging around it does seem to support OpenAPI v3.1, but I had to scrabble around in the code looking for commits like this to find that out as it's just not been mentioned on the repository.
Digging into the functionality now, there are two modes: "dynamic" and "static". Dynamic is where most of the functionality and documentation lays so let's start with that.
Dynamic mode - old school code-first
Here is the code sample from the README.
const fastify = require('fastify')()
await fastify.register(require('@fastify/swagger'), {
openapi: {
openapi: '3.0.0',
info: {
title: 'Test swagger',
description: 'Testing the Fastify swagger API',
version: '0.1.0'
},
tags: [
{ name: 'user', description: 'User related end-points' },
{ name: 'code', description: 'Code related end-points' }
],
components: {
securitySchemes: {
apiKey: {
type: 'apiKey',
name: 'apiKey',
in: 'header'
}
}
}
}
})
fastify.put('/some-route/:id', {
schema: {
description: 'post some data',
tags: ['user', 'code'],
summary: 'qwerty',
security: [{ apiKey: [] }],
params: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'user id'
}
}
},
body: {
type: 'object',
properties: {
hello: { type: 'string' },
obj: {
type: 'object',
properties: {
some: { type: 'string' }
}
}
}
},
response: {
201: {
description: 'Successful response',
type: 'object',
properties: {
hello: { type: 'string' }
}
},
default: {
description: 'Default response',
type: 'object',
properties: {
foo: { type: 'string' }
}
}
}
}
}, (req, reply) => { })
await fastify.ready()
fastify.swagger()General OpenAPI metadata like info, servers, and tags are defined in the middleware registration, then the schema objects are defined on the route.
The only thing OpenAPI-specific about this is the middleware registration, and the "write all the schemas in the routes file" experience is just how the JSON Schema validation already works in Fastly core.
That means people who have never even heard of OpenAPI are already defining these schemas on their routes to get benefits like server-side JSON Schema validation.

Defining all the schemas in the routes, regardless of whether that's a framework convention or not, puts it into the Annotations, Comments, or Decorators category.
Yes those schemas will validate incoming HTTP requests, but the juice is really not worth the squeeze if this is how you have to do it. The cumbersome approaching of having it all in one routes file is going to lead to conflicts, and whilst you can DRY it up, mixing in $ref with JavaScript code is just getting weird. You cannot rely on linters like Spectral or Vacuum to help check if any of its even valid, let alone set up your own API style guides to sniff out problems during development phase.
Working like this is ok for anyone who would rather use JSON Schema to Joi for example, but for the OpenAPI community it's inappropriate for anyone doing anything more than checking a box of "making some OpenAPI docs".
There are two extra plugins to add to get docs out of this OpenAPI. One is the prehistoric Swagger UI that's mainly being updated by a slopbot, and the other is Scalar's API Reference docs which is infinitely more modern, and being actively developed by human beings.
npm install @scalar/fastify-api-referenceWedge it into the routes somewhere. For me this was in plugins/openapi.js which is picked up by @fastify/autoload.
import fp from "fastify-plugin";
import swagger from "@fastify/swagger";
import scalar from "@scalar/fastify-api-reference";
export default fp(async (fastify) => {
await fastify.register(swagger, {
openapi: {
info: {
title: "Train Travel API",
description: "API for finding and booking train trips across Europe.",
# ... snip ...
}
},
});
await fastify.register(scalar, {
routePrefix: "/reference",
});
});
Very simple to add the last few lines, and thanks to some sort of magic with a hidden routing registry, the scalar/fastify-api-reference middleware has everything it needs from fastify/swagger to build out as much documentation as it can from the metadata, annotations, and schema provided in the source code.

This is simply done, but looking a bit sparse, so a lot more keywords and descriptions will need to be thrust into the routes code to make it actually useful as documentation.
Static mode - load in openapi.yaml
If dynamic mode makes you define everything in the routes file, and static lets you load in existing OpenAPI, is that going to help me validate requests from my existing fantastic openapi.yaml?
Here's the code that shows how to make Fastify aware of an existing openapi.yaml that is quite rightly in the source code, and pass it off to Scalar to render beautifully.
const fastify = require("fastify")({ logger: true });
const swagger = require("@fastify/swagger");
const scalar = require("@scalar/fastify-api-reference");
fastify.register(swagger, {
mode: "static",
specification: {
path: "./openapi.yaml",
},
});
fastify.register(scalar, {
routePrefix: "/reference",
configuration: {
title: "API Reference",
},
});
fastify.post("/bookings", async (request, reply) => {});
fastify.post("/stations", async (request, reply) => {});
fastify.post("/trips", async (request, reply) => {});
fastify.listen({ port: 3000 }, (err) => {
if (err) throw err;
});
Run that server and the localhost:3000/reference is now powered by the original openapi.yaml which has been through all sorts of tech writers (who didn't fancy mucking about learning JavaScript to contribute), so the docs are infinitely better fleshed out.

Sadly, this does not provide any request validation. It just serves the docs, and that is all.
Once again, this comes down to the JSON Schema validation being a core feature of Fastify itself. They are not reading the OpenAPI and creating temporary schema/validation objects at build time or run time, which could then be used to validate just like if they were written all over the routes file. This one package could support design-first and code-first nicely if it just did that, but... it does not.
Conflating OpenAPI Schema and JSON Schema
Throughout all of this there are alarm bells ringing around using JSON Schema by default, then trying to shove that into OpenAPI v3.0. Without boring you all with years of trouble, the two schema objects are not actually compatible! OpenAPI v3.0 schemas were a subset and a superset. It took a lot of work to get them lined up in OpenAPI v3.1.
Unless Fastify leverage something like json-schema-to-openapi-schema and/or nudge people towards OpenAPI v3.1+, then this is going to be confusing. That confusion is split between everyone who just hasn't got around to noticing the problems yet, and the even more confusing situation of when the mistakes pop up.
Package 2: fastify-openapi-glue
So, Fastify wants somebody or something to go and build those Schema objects ey?
Thankfully that's exactly the sort of thing computers are good at, so up steps fastify-openapi-glue by seriousme. Right off the bat, there's a lot to like.
- "It aims at facilitating "design first" API development.
- This project replaces fastify-swaggergen to focus on the future of OpenAPI not old timey Swagger.
- It seems to support (or at least accept) OpenAPI v3.1 somewhat.
- It will build your schema objects so you don't have to!
This all sounds a bit good to be true, so let's look at the code real quick then see what the validation looks like.
import Fastify from "fastify";
import scalar from "@scalar/fastify-api-reference";
import openapiGlue from "fastify-openapi-glue";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Service } from "./service.js";
import { Security } from "./security.js";
const fastify = Fastify({
logger: true,
ajv: {
customOptions: {
strict: false, // Allow custom formats without throwing errors
},
},
});
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const openApiPath = `${currentDir}/openapi.yaml`;
const openApiContent = fs.readFileSync(openApiPath, "utf8");
await fastify.register(openapiGlue, {
specification: openApiPath,
serviceHandlers: new Service(),
securityHandlers: new Security(),
});
await fastify.register(scalar, {
routePrefix: "/reference",
configuration: {
title: "API Reference",
content: openApiContent,
},
});
await fastify.listen({ port: 3000 });
Pretty simple on the face of it. Point it at the openapi.yaml and something about running AJV in not-so-strict mode all fine, and Scalar API Reference is there again to show off the docs which is optional.
More light needs to be shed on services and security handlers in a minute, but at this point let's see how validation works.
Let's fire off an invalid POST /bookings request, that has an integer for trip_id instead of the UUID.
curl -s -X POST http://localhost:3000/bookings \
-H "Content-Type: application/json" \
-d '{"trip_id": 123, "passenger_name": "John Doe"}' | jq .
Response:
{
"statusCode": 400,
"code": "FST_ERR_VALIDATION",
"error": "Bad Request",
"message": "body/trip_id must match format \"uuid\""
}Sending in a real UUID however gets through the validation happily.
curl -s -X POST http://localhost:3000/bookings \
-H "Content-Type: application/json" \
-d '{"trip_id": "4f4e4e1c-c824-4d63-b37a-d8d698862f1d", "passenger_name": "John Doe", "has_bicycle": true}' | jq .
Response:
{
"id": "35f7f686-1b78-4489-9e2b-a4d9c07cd08c",
"trip_id": "4f4e4e1c-c824-4d63-b37a-d8d698862f1d",
"passenger_name": "John Doe",
"has_bicycle": true,
"has_dog": false,
"links": {
"self": "http://localhost:3000/bookings/35f7f686-1b78-4489-9e2b-a4d9c07cd08c"
}
}Very nice, thank you for that. Saves me writing up 1000 if conditions or turning OpenAPI into addSchema, Joi, or any one of a thousand other "rewriting your contract out again and again" situations most of us API developers are trying to avoid.
Is it just for POST? Nope! Handles GET and all the other HTTP methods nicely too. Let's see what happens if we just ask for all train trips in all or Europe without any query parameters like a city to go to or from.
curl -s "http://localhost:3000/trips" | jq .The response:
{
"statusCode": 400,
"code": "FST_ERR_VALIDATION",
"error": "Bad Request",
"message": "querystring must have required property 'origin'"
}
Thank you! Fantastic.
The one thing I glossed over to get to the functionality was the security and service classes.
The security handler is simple enough.
export class Security {
async OAuth2(req, scopes, schema) {
// Demo-only auth: allow all requests so you can focus on validation behavior.
return true;
}
}It's easy enough to imagine checking some stuff and returning true or false in there. What about the services?
Using the build in open-glue CLI command, you pass it an OpenAPI document and it generates a whole project folder for you, with empty stubs in the service handler and loads of huge comments that contain all the YAML of the OpenAPI that was passed;.
A few problems immediately show up with this approach.
- When I add more endpoints I cannot run this again without overriding code.
- The stub code being generated has syntax errors.

- I don't want to do any of this!
When one plugin decides that it is going to throw out all the usual way of working, it's a big jolt to the team who are expected to work with it.
Suddenly the team of experienced Fastify developers are not writing Fastify routes and middlewares, they're writing "Fastify OpenAPI Glue Service handlers".
What if they want to use some other plugin which hijacks things similarly extremely?
This might not be a blocker for you, but it would be for me. I just want to let the OpenAPI that's already been defined handle request validation (and ideally response contract testing) in the framework as a vanilla experience instead of inventing a whole new paradigm inside the framework.
Package 3: eropple/fastify-openapi3
A third-party extension has popped up that looks to replace fastify/swagger with a more modern-focused code-first system "whack your schema in the routes" approach, which advertises OpenAPI v3.1 support right off the bat.
This library does take a few opinionated stances, such as requiring the use @sinclair/typebox.
Honourable Mentions
Hey-API
The Fastify integration from new popular SDK generator Hey-API looks pretty good at first...
const fastify = Fastify();
const serviceHandlers: RouteHandlers = {
createPets(request, reply) {
reply.code(201).send();
},
listPets(request, reply) {
reply.code(200).send([]);
},
showPetById(request, reply) {
reply.code(200).send({
id: Number(request.params.petId),
name: 'Kitty',
});
},
};
fastify.register(glue, { serviceHandlers });... but it's just a wrapper around fastify-openapi-glue and still seems to want you to have a one-off generation of handlers which are non-standard Fastify.
PayU/openapi-validator-middleware
This is the only other entry on the Fastify Ecosystem page which mentions OpenAPI is PayU/openapi-validator-middleware, and sadly this is a tool I had to kick off OpenAPI.Tools years ago for inactivity. The last release was Feb 28, 2022, and the tool does not support v3.1.
The last supported version of Fastify was v3, and we are in a v5 world now.
It's a shame because the approach was exactly what is actually needed, and no more.
The requests are validated against OpenAPI in middleware, letting Fastify developers continue to write their code like Fastify developers do, only without needing to spam OpenAPI-kinda-but-not-really all over the source code, and keeping the OpenAPI development out of the way so that Text/GUI editors can be used to manage it, source code can leverage it, contract testing can use it across the. test suite, and if you really need to pull in docs and emit them from the API you can.
Conclusion
Obviously a lot of hard work has gone into all of these tools over the years, and OpenAPI is not an easy space for individual developers or small teams to keep up with (especially with OpenAPI v3.1 introducing breaking changes...) but this usually just means we need better collaboration and more reliance on shared utility packages instead of everyone reinventing the sausage single individually.
Based on whats on offer, if you prefer code-first maybe see how far you can get with @fastify/swagger or eropple/fastify-openapi3.
If you're design-first, maybe the approach taken by fastify-openapi-glue is not so bad once you're used to it?
Wait, I have an idea,
Package 4: Slap a middleware together yourself
If you agree that all you really need is a middleware, there are quite a few out there which are not listed on the Fastify Ecosystem page, but could work either out of the box or with a bit of tinkering.
I've got my eye on openapi-data-validator.js from Authress Engineering and friend of the community Warren Parad.
curl -X POST http://localhost:3000/bookings \
-H "Content-Type: application/json" \
-d '{"passenger_name": "Jane Doe"}' | jq .Got it working quite nicely.
{
"statusCode": 400,
"error": "Bad Request",
"message": "missing required property request.body.trip_id",
"errors": [
{
"path": ".body.trip_id",
"message": "must have required property 'trip_id'",
"fullMessage": "missing required property request.body.trip_id"
}
]
}All that took was this:
import Fastify from "fastify";
import { createRequire } from "node:module";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const { OpenApiValidator } = require("openapi-data-validator");
const specPath = join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml");
const fastify = Fastify({ logger: true });
const validate = new OpenApiValidator({ apiSpec: specPath }).createValidator();
fastify.addHook("preHandler", async (request, reply) => {
try {
await validate({
method: request.method,
route: request.routeOptions.url,
headers: request.headers,
query: request.query,
body: request.body,
path: request.params,
});
} catch (err) {
return reply.status(err.status ?? 400).send({
statusCode: err.status ?? 400,
error: "Bad Request",
message: err.message,
errors: err.errors,
});
}
});
fastify.post("/bookings", async (request, reply) => {
return reply.status(201).send({
id: crypto.randomUUID(),
trip_id: request.body.trip_id,
passenger_name: request.body.passenger_name,
has_bicycle: request.body.has_bicycle ?? false,
has_dog: request.body.has_dog ?? false,
});
});
await fastify.listen({ port: 3000 });
Perfectly vanilla Fastify setup, all conventions intact, but I can rely on OpenAPI being validated as pre-hook before my route handlers are even touched, meaning they don't need anywhere near as much manual validation inside the handler.
For me, this would do the job perfectly. If I wanted to publish docs I'd simply publish directly to Scalar with Github Action or CLI/CI, instead of awkwardly hosting it in the API itself. Same goes for SDKs, I'd trigger a new build of Speakeasy on merge to main and keep SDK logic out of this individual API, which could be one of many.
Perhaps somebody could wrap this approach up ☝️ and create fastify-openapi-middleware, and remove the old middleware off the Fastify Ecosystem page as it's long dead.
Let me know if you do! 🫡