Use Fastify JSON Schemas as Your API Contract
Stop duplicating validation, serialization, and docs. Fastify route schemas compile once and enforce the contract before your handler runs.
09 Dec 2025, 09:13 UTC

The problem: validation, shaping, and docs in three places
Many Node.js teams validate request bodies with middleware, shape responses inside handlers, and maintain OpenAPI YAML by hand. The three copies inevitably drift: a required field added to the body check is forgotten in the response object, or the documentation still lists an old query parameter. By the time a tester notices, the API contract exists nowhere in particular.
Thesis: declare the contract once in the route schema
Fastify lets you attach a JSON Schema to each route for body, querystring, params, and headers. The framework compiles the schema with AJV during registration, not on every request. When a request arrives, Fastify validates it before the handler runs and returns a structured 400 automatically. If you declare a response schema for a status code, Fastify serializes the handler's return value with fast-json-stringify and omits properties the schema does not declare.
This makes the route schema the single source of truth for validation, serialization, and—with the OpenAPI plugin—documentation.
Worked example: POST /users
Run this in your project directory with Node installed. No special permissions are required.
const Fastify = require('fastify')
const fastify = Fastify({ logger: true })
fastify.post('/users', {
schema: {
body: {
type: 'object',
required: ['name', 'email'],
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' }
},
additionalProperties: false
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const user = { id: 'user_123', name: request.body.name, internalNote: 'do not leak' }
return reply.code(201).send(user)
})
fastify.listen({ port: 3000 }, (err) => {
if (err) throw err
})
Send a malformed payload:
curl -X POST http://localhost:3000/users \
-H 'Content-Type: application/json' \
-d '{"name": 42}'
Expected check: Fastify replies with 400 before the handler executes. The handler never sees the invalid body.
Send a valid payload:
curl -X POST http://localhost:3000/users \
-H 'Content-Type: application/json' \
-d '{"name": "Ada", "email": "[contact removed]"}'
Expected check: the response contains id and name only. The internalNote field is omitted because it is not declared in the 201 response schema.
Risk: if you later add a field to the handler output but forget the response schema, Fastify silently drops it. Treat response schemas as a contract, not decoration.
Trade-offs and limitations
- Startup cost. Schemas compile during registration. Complex schemas with many
anyOforoneOfbranches can slow startup and consume more memory. Keep hot-path schemas focused. - Version differences. Option names, default error shapes, and AJV behavior differ across Fastify major versions. Confirm behavior against your installed release before relying on defaults.
- Structural only. JSON Schema validates shape, not semantics. It does not replace authorization, uniqueness checks, or business rules such as "email already registered."
- Custom formats. Built-in format validation such as
emaildepends on the AJV configuration and Fastify version. If you need custom keywords or coercion, you must set a validator compiler explicitly.
Practical verification
- Check your installed version:
npm list fastifyornode -e "console.log(require('fastify/package.json').version)". - Register one route with a body schema and POST an invalid payload. Confirm the automatic 400 and inspect the error body.
- Add a response schema, return an object with an extra undeclared field, and confirm that field is absent from the serialized response.
- If startup feels slow, time route registration with and without schemas on a representative route set.
Closing
Start with one high-traffic route. Move its body validation and response shape into the route schema, verify the 400 behavior and field filtering, then expand to the rest of the API. The payoff is fewer places where the contract can drift, and validation that is compiled once instead of interpreted on every request.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.