# 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.

- Autore / Author: Diego Betto (https://diegobetto.com/about/)
- Pubblicato / Published: 2026-08-27
- URL: https://diegobetto.com/en/fastify-rest-api-typescript/
- Tag: fastify, nodejs, typescript, api

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.

> 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

```bash
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:

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

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

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

## The minimal server

```typescript
// 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`:

```typescript
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:

```bash
npm install @fastify/type-provider-typebox
```

```typescript
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**.

```typescript
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.

> 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`:

```typescript
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](https://diegobetto.com/nodejs-security-release-2026/) worth keeping an eye on when choosing which version to build on in production.
