Diego Betto's Blog
Fastify logo, the web framework for Node.js

August 27, 2026 · 6 min di lettura

Building a REST API with Fastify and TypeScript

A practical guide to Fastify with TypeScript: routing, schema validation, and plugin architecture, to build a fast, typed REST API from scratch.

Condividi:XLinkedInFacebookWhatsApp

For years, nearly every Node.js project I wrote started from npx express-generator. Then I found myself having to optimize an API that needed to handle a few thousand requests per second, and I discovered that most of the time went to the least interesting place possible: parsing and validating request bodies, done by hand with express-validator scattered across twenty different routes.

Fastify was built exactly for this problem. It’s not “Express but faster” — even though it is, by quite a lot — it’s a framework that treats schema and validation as first-class citizens instead of a plugin bolted on afterward.

Why Fastify and not Express

Three differences actually matter, the rest is marketing:

  1. Schemas aren’t just for validating input, they’re also used to serialize output. Fastify uses fast-json-stringify to generate a serialization function tailored to each route’s response schema — it’s faster than generic JSON.stringify(), and in an API that responds with large payloads the difference is noticeable.
  2. The architecture is plugin-based with encapsulation, not a single global app object that everyone mounts middleware onto in whatever order. More on this below — it’s the part quick tutorials almost always skip.
  3. Validation is declarative, not imperative: you describe the shape of the data with a schema, and Fastify generates the validator at build time instead of running a chain of if checks on every request.

💡 Consiglio

When Express is still the right choice: if the project is already written in Express and works, rewriting it for a few percentage points of extra throughput almost never pays off. Fastify pays off mostly for new projects, or for services where input validation is central (public APIs, high-traffic microservices).

Project setup

mkdir my-api && cd my-api
npm init -y
npm install fastify
npm install -D typescript tsx @types/node
npx tsc --init

In the generated tsconfig.json, the options that matter for Fastify are these:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist"
  }
}

And two scripts in package.json for development and build:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

The minimal server

// src/server.ts
import Fastify from "fastify";

const app = Fastify({ logger: true });

app.get("/health", async () => {
  return { status: "ok" };
});

app.listen({ port: 3000 }, (err) => {
  if (err) {
    app.log.error(err);
    process.exit(1);
  }
});

logger: true isn’t decorative: Fastify uses pino internally, so you get production-ready structured JSON logging without adding anything.

Validating (and serializing) with schemas

This is where Fastify really sets itself apart. Every route accepts a schema object with body, params, querystring, and response:

app.post(
  "/users",
  {
    schema: {
      body: {
        type: "object",
        required: ["email", "name"],
        properties: {
          email: { type: "string", format: "email" },
          name: { type: "string", minLength: 2 },
        },
      },
      response: {
        201: {
          type: "object",
          properties: {
            id: { type: "string" },
            email: { type: "string" },
            name: { type: "string" },
          },
        },
      },
    },
  },
  async (request, reply) => {
    const { email, name } = request.body as { email: string; name: string };
    const user = await createUser({ email, name });
    reply.code(201);
    return user;
  },
);

If the body doesn’t match the schema, Fastify responds with 400 automatically, before your handler is even called. And schema.response isn’t just documentation: Fastify uses it to build the fast serializer mentioned above — any property you return that isn’t in the schema gets silently excluded from the response, which is also a great safety net against accidental data leaks (think of a passwordHash field you forgot to exclude by hand).

Typing routes with TypeScript

Writing request.body as { email: string; name: string } works but isn’t real typing — it’s just telling TypeScript “trust me.” To get real inference, the most direct way is @fastify/type-provider-typebox, which generates TypeScript types directly from the same JSON schemas you already use for validation:

npm install @fastify/type-provider-typebox
import { Type, type Static } from "@sinclair/typebox";
import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";

const app = Fastify().withTypeProvider<TypeBoxTypeProvider>();

const CreateUserBody = Type.Object({
  email: Type.String({ format: "email" }),
  name: Type.String({ minLength: 2 }),
});
type CreateUserBody = Static<typeof CreateUserBody>;

app.post(
  "/users",
  {
    schema: { body: CreateUserBody },
  },
  async (request) => {
    // request.body is already typed as CreateUserBody, no "as" needed
    const { email, name } = request.body;
    return createUser({ email, name });
  },
);

One schema, two uses: validates at runtime and generates types at compile time. No drift between “what I tell TypeScript” and “what I actually check.”

The plugin architecture: the idea that changes how you organize code

In Express, you typically have a global app on which you mount middleware and routers in an order that matters, and any file that imports app can add anything to that shared object. Fastify starts from a different idea: every plugin has its own encapsulated scope.

import type { FastifyPluginAsync } from "fastify";

const usersPlugin: FastifyPluginAsync = async (fastify) => {
  // fastify here is a child instance, isolated from the caller's
  fastify.decorateRequest("currentUser", null);

  fastify.addHook("preHandler", async (request) => {
    request.currentUser = await getUserFromToken(request.headers.authorization);
  });

  fastify.get("/me", async (request) => {
    return request.currentUser;
  });
};

app.register(usersPlugin, { prefix: "/users" });

The currentUser decorator and the preHandler hook registered inside usersPlugin only exist inside that plugin and its children, they don’t leak into the rest of the app. You can register two different plugins with hooks or decorators of the same name without them conflicting, because they live in separate scopes — the same logic as ES modules, applied to routes instead of files.

⚠ Attenzione

The most common mistake: forgetting await. fastify.register() is asynchronous — if you register a plugin that decorates fastify with something (a database client, a decorator) and then try to use it right after without await, that decorator might not exist yet. In practice: always use await app.register(...) in sequence, or let Fastify manage the order with fastify-plugin if the plugin needs to expose something to the parent scope.

A rookie mistake worth naming

If your plugin needs to decorate the parent instance (e.g. adding a shared Redis client used by the whole app, not just a sub-scope), unintentionally encapsulating the plugin makes it invisible to the rest of the app. The fix is fastify-plugin:

import fp from "fastify-plugin";

export default fp(async (fastify) => {
  fastify.decorate("redis", createRedisClient());
});

fp() tells Fastify “don’t encapsulate this, let the decorators leak upward.” It’s the detail that separates a plugin that seems to work in isolation from one that integrates correctly with the rest of the app.

What’s next?

With routing, validation, and plugin architecture in place, you have the foundations for a real API, not just a toy endpoint. The natural next steps are authentication (Fastify has official @fastify/jwt and @fastify/cookie) and data persistence — if your stack also touches Node.js dependency security, I’ve also written a rundown of the latest Node.js security releases worth keeping an eye on when choosing which version to build on in production.

Condividi:XLinkedInFacebookWhatsApp