A Fastify 5 server architecture that resolves TypeScript type inference, runtime validation, and OpenAPI documentation all at once with a single Zod schema
TypeScript REST API projects inevitably reach a point where three files start drifting out of sync. You add a field to a Zod schema and forget to update the OpenAPI YAML, or you change a type definition while the validation logic falls behind. Catching that drift in PR review before it becomes a real bug requires the whole team to habitually check all three places at once — and that cost is higher than it sounds.
The combination of Fastify 5 and @fastify/type-provider-zod blocks this problem structurally. Write a Zod schema once, and TypeScript types are automatically inferred at compile time, HTTP request data is validated at runtime, and an OpenAPI 3.x document viewable in Swagger UI is generated automatically. No code generation step, no separate config file.
This article covers how these three things actually connect under the hood, and setup patterns you can use in production right away. It targets backend developers who are already reasonably comfortable with TypeScript and Zod. Even if you're new to Fastify, Express experience is enough to follow along.
Core Concepts
Fastify 5: A High-Performance Framework With Legacy Stripped Out
Fastify v5 was released by the Fastify team itself in September 2024. The OpenJS Foundation provides the governance foundation; the framework team drove the release. The prevailing view is that v5 is a "foundation-building" release — not a feature-heavy major version, but one that removes accumulated deprecated APIs and optimizes for modern environments running Node.js v20 and above.
On performance, it uses fast-json-stringify for JSON serialization, and its compile-time serialization/deserialization structure based on JSON Schema delivers significantly higher throughput than Express. Official benchmarks show it well ahead of Express on both req/s and latency on a single core, with weekly npm downloads in the millions and hundreds of contributors — a well-validated ecosystem.
@fastify/type-provider-zod: The Official Adapter
This package started as a separate repository called turkerdev/fastify-type-provider-zod. It grew popular enough that the repository was transferred wholesale into the Fastify official organization (fastify/) and now has first-class support. It's not a fork where the original remains separately — the repository itself moved into the official namespace. It is the de facto standard for Zod + Fastify.
In one sentence, what this library does is act as an adapter connecting Zod schemas to Fastify's type system and OpenAPI pipeline. The major exports make the roles clear:
import {
ZodTypeProvider, // Type-level connection — withTypeProvider<ZodTypeProvider>()
validatorCompiler, // Runtime validation
serializerCompiler, // Response serialization
jsonSchemaTransform, // Per-route Zod → JSON Schema conversion
jsonSchemaTransformObject, // App-wide spec conversion
FastifyPluginAsyncZod, // Plugin type helper (alternative to withTypeProvider)
} from "@fastify/type-provider-zod";One Zod Schema, Three Outputs
The core philosophy in diagram form:
flowchart LR
Z["Zod Schema"]
Z --> T["TypeScript Type Inference"]
Z --> V["Runtime Validation"]
Z --> O["OpenAPI Spec Generation"]
T --> DX["Type-Safe Handler"]
V --> E["Automatic 400 Error Response"]
O --> S["Swagger UI Rendering"]Static type inference kicks in the moment you wrap a route with withTypeProvider<ZodTypeProvider>(). Hovering over req.body inside a handler gives you the TypeScript type automatically inferred from the Zod schema, with IDE autocomplete included.
Runtime validation is handled by validatorCompiler. When an HTTP request arrives, Fastify parses the body, querystring, params, and headers against their respective Zod schemas. If validation fails, a 400 response is returned automatically.
OpenAPI spec generation: jsonSchemaTransform converts each route's Zod schema to JSON Schema, and @fastify/swagger assembles that into an OpenAPI 3.x document. No separate YAML file or decorators — the route definition itself is the source of the documentation.
The absence of a code generation step is the key point. Unlike tRPC or OpenAPI Generator, you don't add a separate step to your build pipeline. All transformations happen at server startup.
Zod v4 Support and What Changed
As of 2025, @fastify/type-provider-zod v7 and above supports Zod 4.x. Notable changes:
| Item | v6 and below (Zod 3) | v7 and above (Zod 4) |
|---|---|---|
| Response serialization basis | z.input<T> |
z.output<T> |
| Parsing performance | Baseline | Up to 14× faster |
| Global schema registry | Not supported | z.globalRegistry supported |
| OpenAPI $ref reuse | Limited | Auto-generated via .meta({ id }) |
| String format API | z.string().email() method chain |
z.email() top-level function (method chain deprecated) |
In Zod v4, method-chain forms like z.string().email() are deprecated, and top-level function forms like z.email(), z.url(), and z.uuid() have been introduced. All code examples in this article are written in v4 style.
The shift from z.input to z.output as the basis for serialization also matters. If you have schemas with .transform() where input and output types differ, you may encounter type errors when migrating from v3 to v4:
// Example schema with transform
const DateSchema = z.object({
createdAt: z.string().transform((s) => new Date(s)),
});
// Zod v3: response serialization based on z.input → expects string type
// Zod v4: response serialization based on z.output → expects Date type
// → In v4, if serializerCompiler tries to put a Date object directly in the response,
// a type mismatch error occurs during JSON serializationWhen upgrading from v3 to v4, be sure to review any response schemas that use .transform().
When Should You Use This?
It's important to clarify the technology stack choices first. Fastify + Zod is not the right choice for every situation.
flowchart TD
Start["REST API + TypeScript Start"]
Q1{"TypeScript-only clients too?"}
Q2{"Is OpenAPI documentation needed?"}
Q3{"Is edge runtime required?"}
Start --> Q1
Q1 -->|"Yes"| tRPC["tRPC\nStrongest type safety"]
Q1 -->|"Various clients"| Q2
Q2 -->|"Not needed"| Q3
Q3 -->|"Yes"| Hono["Hono + Zod\nLightweight, edge-friendly"]
Q3 -->|"No"| Fastify["Fastify 5 + type-provider-zod\nPerformance + automated docs"]
Q2 -->|"Needed, code generation allowed"| NestJS["NestJS + nestjs-swagger\nEnterprise structure"]- tRPC: Strongest type safety when both client and server are TypeScript. When you need to integrate with external teams or non-browser clients, the need for OpenAPI arises.
- Hono + Zod: Suited for edge runtime environments like Cloudflare Workers and Deno Deploy. When bundle size matters.
- NestJS: When you prefer a decorator-based enterprise structure, or the team is already familiar with the NestJS ecosystem.
- Fastify 5 + type-provider-zod: When you need OpenAPI documentation and want to maintain a single source of truth without code generation. When performance also matters.
Practical Application
Basic Setup: Three Essential Configuration Steps
When configuring a Fastify app correctly, the order of compiler registration matters. A common mistake is registering only validatorCompiler and leaving out serializerCompiler — you need both.
import Fastify from "fastify";
import {
serializerCompiler,
validatorCompiler,
ZodTypeProvider,
jsonSchemaTransform,
jsonSchemaTransformObject,
} from "@fastify/type-provider-zod";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUI from "@fastify/swagger-ui";
const app = Fastify({ logger: true });
// Essential 1: Register both compilers
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
// Essential 2: Register the Swagger plugin before routes
await app.register(fastifySwagger, {
openapi: {
openapi: "3.0.3",
info: { title: "My API", version: "1.0.0" },
},
transform: jsonSchemaTransform,
transformObject: jsonSchemaTransformObject,
});
await app.register(fastifySwaggerUI, { routePrefix: "/docs" });Defining Type-Safe Routes
Chaining withTypeProvider<ZodTypeProvider>() causes the type of req.body inside the handler to be automatically inferred from the Zod schema. This approach is well-suited for defining a single route inline.
import { z } from "zod";
app.withTypeProvider<ZodTypeProvider>().route({
method: "POST",
url: "/users",
schema: {
tags: ["users"],
summary: "Create user",
body: z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email(), // Zod v4 style: use instead of z.string().email()
role: z.enum(["admin", "viewer"]).default("viewer"),
}),
response: {
201: z.object({
id: z.number(),
name: z.string(),
email: z.email(),
role: z.enum(["admin", "viewer"]),
}),
},
},
handler: async (req, reply) => {
// req.body is { name: string, email: string, role: "admin" | "viewer" }
// Both IDE autocomplete and type checking work
const user = await createUser(req.body);
return reply.status(201).send(user);
},
});Request Processing Flow
Here's the internal processing order when an actual HTTP request arrives:
sequenceDiagram
participant C as Client
participant F as Fastify
participant V as validatorCompiler
participant H as Handler
participant S as serializerCompiler
C->>F: POST /users request
F->>V: Parse body against Zod schema
alt Validation fails
V-->>C: Automatic 400 Bad Request response
else Validation succeeds
V->>H: Pass type-inferred req.body
H->>S: Call reply.send
S-->>C: JSON serialized response
endValidating querystring and params
Beyond the body, query strings and URL parameters can be validated the same way. However, watch out for boolean parameter handling.
app.withTypeProvider<ZodTypeProvider>().get(
"/users/:id",
{
schema: {
params: z.object({
id: z.coerce.number().int().positive(),
// URL params always arrive as strings.
// z.coerce.number() handles the Number("123") → 123 conversion.
}),
querystring: z.object({
// Don't use z.coerce.boolean().
// Boolean("false") === true, so ?includeDeleted=false parses as true.
includeDeleted: z.preprocess(
(v) => (v === undefined ? undefined : v === "true"),
z.boolean().optional().default(false)
),
}),
response: {
200: z.object({
id: z.number(),
name: z.string(),
role: z.enum(["admin", "viewer"]),
}),
},
},
},
async (req, reply) => {
// req.params.id is number, req.query.includeDeleted is boolean
const user = await findUser(req.params.id, req.query.includeDeleted);
return reply.send(user);
}
);z.coerce.number() works well for numbers. Number("123") === 123, and invalid strings produce NaN, which causes Zod validation to fail. z.coerce.boolean(), on the other hand, yields Boolean("false") === true, so any non-empty string is treated as true. Sending ?includeDeleted=false would result in true. Handle boolean query parameters explicitly with z.preprocess.
Reusing $ref with Zod v4 Global Registry
Using the global registry introduced in Zod 4, @fastify/swagger automatically generates reusable schemas as OpenAPI $ref components. When multiple endpoints reference a shared schema, the OpenAPI document becomes much cleaner.
import { z } from "zod";
const UserSchema = z
.object({
id: z.number(),
name: z.string(),
email: z.email(),
})
.meta({ id: "User" });
// Adding .meta({ id: "User" }) causes the OpenAPI document to
// reference it as $ref: '#/components/schemas/User'
app.withTypeProvider<ZodTypeProvider>().get(
"/users/:id",
{
schema: {
params: z.object({ id: z.coerce.number() }),
response: { 200: UserSchema },
},
},
async (req, reply) => {
const user = await findUser(req.params.id);
return reply.send(user);
}
);Separating Routes with the Plugin Pattern
In real projects, it's common to separate routes into plugins. There are two approaches, with different recommended usage patterns.
withTypeProvider<ZodTypeProvider>(): Suited for defining routes inline at the file or app level.FastifyPluginAsyncZod: Recommended when separating routes into separate files (plugins). Inside the plugin, ZodTypeProvider is already applied without needing to callwithTypeProvideragain.
// routes/users.ts
import { FastifyPluginAsyncZod } from "@fastify/type-provider-zod";
import { z } from "zod";
const usersPlugin: FastifyPluginAsyncZod = async (app) => {
// ZodTypeProvider is already applied without withTypeProvider
app.get(
"/",
{
schema: {
response: { 200: z.array(UserSchema) },
},
},
async (req, reply) => {
return reply.send(await getAllUsers());
}
);
app.post(
"/",
{
schema: {
body: z.object({
name: z.string().min(2),
email: z.email(),
}),
response: { 201: UserSchema },
},
},
async (req, reply) => {
const user = await createUser(req.body);
return reply.status(201).send(user);
}
);
};
export default usersPlugin;// app.ts
await app.register(usersPlugin, { prefix: "/users" });Mixing the two patterns can cause type contexts to nest unintentionally. If your structure is plugin-based, it's better to stay consistent with FastifyPluginAsyncZod.
Pros and Cons Analysis
An Honest Look at Trade-offs
| Category | Detail |
|---|---|
| ✅ Single source of truth | One Zod schema manages types, validation, and documentation simultaneously. Code drift is structurally blocked |
| ✅ No code generation | No extra step in the build pipeline. Schema changes are reflected in the docs immediately |
| ✅ High performance | Higher throughput than Express. Zod v4 parsing performance up to 14× faster |
| ✅ Developer experience | Automatic type inference for req.body inside handlers + IDE autocomplete |
| ✅ Ecosystem integration | Instant interop with various OpenAPI renderers including Swagger UI, Scalar, and Redoc |
| ⚠️ Zod v3→v4 migration | v7 and above requires Zod 4. The shift to z.output-based serialization can cause type errors in existing transform schemas |
| ⚠️ Limits of Zod-specific features | z.lazy() recursive types, complex discriminated unions, etc. may not render as intended in OpenAPI documentation |
| ⚠️ OpenAPI 3.0 vs 3.1 | Differences in nullable handling can cause rendering discrepancies in Swagger UI. Check which version your target tools support |
| ⚠️ Runtime overhead | Zod parsing adds cost over pure JSON Schema approaches, but Zod v4 performance improvements make it rarely a bottleneck in practice |
Common Mistakes in Production
Mistake 1: Registering only one of the two compilers
If you register only validatorCompiler and omit serializerCompiler, response serialization ignores the Zod schema and falls back to default behavior.
// Easy mistake to make
app.setValidatorCompiler(validatorCompiler);
// serializerCompiler missing!
// Correct form
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);Mistake 2: Registering the Swagger plugin after routes
@fastify/swagger must be registered before routes are registered in order to collect the spec correctly. If the order is reversed, some routes will be missing from the documentation.
Mistake 3: Using z.coerce.boolean() for boolean parameters
As explained earlier, ?includeDeleted=false gets parsed as true. Use z.preprocess for explicit conversion.
Mistake 4: Not specifying the OpenAPI version
If the default is 3.1, older Swagger UI versions or client tools may have compatibility issues. It's better to specify it explicitly, like openapi: "3.0.3".
Closing Thoughts
The code drift problem isn't just inconvenient. When types and validation are out of sync in production, it leads to real bugs: the frontend team receives responses that don't match the documentation, or invalid data gets stored in the database. This structure is actually most valuable not at the start of development, but once API endpoints exceed a few dozen and multiple people start modifying schemas simultaneously.
One production experience worth sharing: after adopting this stack, the first communication overhead to disappear was the report of "the OpenAPI docs don't match the code." Because the moment a schema changes the docs change with it, the task of verifying whether documentation is up to date simply ceases to exist.
On the flip side, there are things to check before adopting it. If you have many recursive schemas using z.lazy() or complex union types, it's worth verifying early on that they render as intended in the OpenAPI documentation. Zod's expressiveness doesn't always map 1:1 with the representational range of the OpenAPI spec.
If you want to get started right now:
- Install packages:
npm install fastify @fastify/type-provider-zod @fastify/swagger @fastify/swagger-ui zod - Register compilers: Register both
setValidatorCompilerandsetSerializerCompiler, and registerfastifySwaggerbefore your routes. - Write your first route: Register a Zod schema with
withTypeProvider<ZodTypeProvider>().route(), then check the auto-generated documentation at/docs.
References
- fastify/fastify-type-provider-zod - GitHub
- Fastify v5 Official Docs - Type Providers
- Fastify v5 Migration Guide
- Fastify v5 is Here! - Platformatic Blog
- Fastify v5 Official Announcement - OpenJS Foundation
- fastify-zod-openapi - GitHub
- fastify/fastify-swagger - GitHub
- Generate OpenAPI with Fastify - Speakeasy
- Fastify + Neon + Zod Full-Stack Guide - Neon Docs
- NestJS vs Fastify 2026 Comparison - Encore
- Zod v4 Official Documentation