Express is the default, but when an API needs real throughput and disciplined validation, Fastify is the better tool. Its schema-first design validates and serialises against JSON Schema — which is both a correctness win and, because serialisation is compiled, a genuine performance win. Here is how I structure a Fastify service.
Schema-validated routes
The core idea: declare the shape of the body, params, querystring and response as JSON Schema on the route. Fastify validates inbound data automatically (reject bad requests before your handler runs) and compiles a fast serialiser for the response.
import Fastify from "fastify";
const app = Fastify({ logger: true });
app.post(
"/bookings",
{
schema: {
body: {
type: "object",
required: ["guestId", "roomId", "checkIn", "checkOut"],
properties: {
guestId: { type: "string", format: "uuid" },
roomId: { type: "string" },
checkIn: { type: "string", format: "date" },
checkOut: { type: "string", format: "date" },
},
},
response: {
201: {
type: "object",
properties: { id: { type: "string" }, status: { type: "string" } },
},
},
},
},
async (req, reply) => {
const booking = await createBooking(req.body);
reply.code(201);
return booking; // serialised against the 201 schema — fast + leak-proof
},
);The response schema is not just docs — Fastify compiles it into a serialiser that is dramatically faster than JSON.stringify AND strips any field not in the schema, so you can never accidentally leak an internal column to the client.
Typed routes with the type provider
Pair the JSON schema with a type provider so your handler's req.body is fully typed from the same schema — one source of truth, no drift between validation and types.
import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";
import { Type } from "@sinclair/typebox";
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>();
const Booking = Type.Object({
guestId: Type.String({ format: "uuid" }),
roomId: Type.String(),
});
app.post("/bookings", { schema: { body: Booking } }, async (req) => {
// req.body is typed as { guestId: string; roomId: string }
return createBooking(req.body);
});Encapsulation: the plugin model
Fastify's superpower is encapsulation. A plugin gets its own scope — decorators, hooks and middleware registered inside it don't leak out. This is how you compose a large API without a tangle of global middleware.
// auth.plugin.ts — an encapsulated unit
import fp from "fastify-plugin";
export default fp(async (app) => {
app.decorateRequest("user", null);
app.addHook("preHandler", async (req, reply) => {
const token = req.headers.authorization?.replace("Bearer ", "");
const user = await verify(token);
if (!user) return reply.code(401).send({ error: "Unauthorized" });
req.user = user;
});
});
// register it only on the routes that need it
app.register(async (secured) => {
secured.register(authPlugin);
secured.get("/me", async (req) => req.user);
});fastify-plugin (fp) breaks encapsulation deliberately — use it when a plugin should share its decorators with the parent scope (like a db connection). Omit it when you want the plugin's changes contained. Knowing which you want is most of mastering Fastify.
Hooks over scattered middleware
- onRequest — earliest, before body parsing: rate limiting, auth short-circuits.
- preHandler — after validation, before your handler: authorization, loading related data.
- onSend — mutate the response payload (add headers, redact fields).
- onError — centralised error shaping so every route returns a consistent error body.
The result is an API where validation, types and serialisation all derive from one schema, auth is a contained plugin you opt routes into, and the hot path is compiled rather than interpreted. That combination is why Fastify wins when both safety and speed are on the line.