Breaking
Build a Fastify + TypeScript REST API

Build a Fastify + TypeScript REST API

How this was written

Drafted in plain Markdown by Ethan Laurent and edited against current Node.js, framework and tooling docs. Every command, code block and benchmark in this article was run on Node 24 LTS before publish; if a step does not work on your machine the post is wrong, not you — email and I will fix it.

AI is used as a research and outline assistant only — never as a single-source author. Full editorial policy: About / How nodewire is written.

The Express service I inherited last autumn was spending more time in JSON.stringify and ad-hoc req.body checks than in actual business logic. I profiled a single list endpoint at around 9,000 req/s on my M2, and a third of the wall time was serialization and hand-written validation guards that still let bad payloads through. I rewrote it as a Fastify TypeScript REST API over a weekend. Same Postgres, same Docker image, same Node 22. The list endpoint cleared 18k req/s, the validation guards disappeared into schemas, and request.body was finally typed without me writing a single interface twice.

The API structure

A clean Fastify TypeScript REST API starts with Fastify v5, route schemas, a type provider, reusable plugins, a service layer, one central error handler, and authentication in hooks instead of copy-pasted route guards. The main SEO and developer value here is not “Fastify is fast” — it is schema-first APIs that stay typed at runtime.

That last part is the reason I reach for Fastify now instead of Express. The schema you write for validation is the same schema TypeScript reads to type your handler. One source of truth. This guide builds a small books CRUD API that shows the whole loop: setup, typed routes, schema validation, plugins, a service layer, errors, serialization, and an auth hook. Every snippet runs on clean Node 20+ with Fastify v5.

If you’re still deciding between the two frameworks at all, For a longer head-to-head, see Express vs Fastify. This piece assumes you’ve decided.

Project setup without the boilerplate fatigue

The problem with most “getting started” setups is they leave you with ts-node, a nodemon config, and a build that drifts from what actually runs in production. Node 20+ fixed enough of this that you can keep the toolchain thin.

Fastify v5 dropped Node 18 — it needs Node 20 or newer — so check your version first, then create the project.

bash
node --version   # must be >= 20
mkdir books-api && cd books-api
npm init -y
npm install fastify@5 fastify-type-provider-zod zod @fastify/autoload @fastify/jwt
npm install -D typescript @types/node tsx

I use tsx for the dev loop because it runs TypeScript directly with no separate compile step and reloads on change. For the production build I still emit real JavaScript with tsctsx is a dev convenience, not a deploy strategy.

Now the tsconfig.json. The two settings people get wrong are module and strict. Use modern module resolution and keep strict on, because the whole point here is letting the compiler catch what you’d otherwise validate by hand.

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"]
}

Add scripts to package.json:

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

If you want the longer rationale on each compiler flag, I keep a fuller writeup in the TypeScript Node.js setup guide. Either way, npm run dev should now be your loop.

The server and a first typed route

Plain Fastify gives you a fast server, but out of the box request.body is typed as unknown and your responses go through generic JSON.stringify. You fix both by attaching a type provider once, at the instance level.

I use the Zod type provider because Zod schemas double as runtime validators and as the source TypeScript reads for types. The current package — fastify-type-provider-zod v6 — targets Zod 4, so import z from the zod/v4 entry point. (Fastify’s own Type-Providers reference documents the Zod and TypeBox options if you want the framework’s take.)

TypeScript
// src/server.ts
import Fastify from "fastify";
import {
  serializerCompiler,
  validatorCompiler,
  type ZodTypeProvider,
} from "fastify-type-provider-zod";
import { z } from "zod/v4";

const app = Fastify({ logger: true }).withTypeProvider<ZodTypeProvider>();

app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);

app.get(
  "/health",
  {
    schema: {
      response: { 200: z.object({ status: z.literal("ok") }) },
    },
  },
  async () => {
    return { status: "ok" as const };
  },
);

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

Three things earn their keep here. The withTypeProvider call flips on schema-driven types for every route after it. The two compiler setters tell Fastify to validate input and serialize output through Zod. And host: "0.0.0.0" matters the day you containerize — bind to localhost only and your Docker container answers nobody. Hit curl localhost:3000/health and you get {"status":"ok"}.

Schema validation that types your request body

Hand-written validation rots. Someone adds a field to the body, forgets the guard, and you ship a 500 that should have been a 400. The schema-first approach makes the validator and the type the same object, so they can’t drift.

Here’s a create-book route. Notice I never declare a Book input interface — request.body is inferred straight from the schema.

TypeScript
// src/routes/books.ts (excerpt)
import { z } from "zod/v4";

const createBookBody = z.object({
  title: z.string().min(1).max(200),
  author: z.string().min(1),
  year: z.number().int().gte(1450).lte(2100),
});

app.post(
  "/books",
  {
    schema: {
      body: createBookBody,
      response: {
        201: z.object({ id: z.string().uuid(), title: z.string() }),
      },
    },
  },
  async (request, reply) => {
    // request.body is typed { title: string; author: string; year: number }
    const { title, author, year } = request.body;
    const book = await bookService.create({ title, author, year });
    return reply.code(201).send({ id: book.id, title: book.title });
  },
);

Send { "title": "", "year": 1200 } and Fastify rejects it before your handler runs, returning a 400 with the failing path. No if (!body.title) ladder. If you want to go deeper on Zod patterns — refinements, transforms, discriminated unions — I have a dedicated piece on Zod validation for Node APIs. Prefer JSON Schema with no extra dependency? Fastify validates inline JSON Schema natively, and @fastify/type-provider-typebox (with TypeBox as a peer dependency) gives you the same typed-body trick using Type.Object. Pick one and stay with it; mixing both in one codebase is how you end up with two ways to describe every shape.

Organizing with plugins and @fastify/autoload

A single server.ts is fine until it’s 600 lines. The real problem is wiring: every new route file means another import and another app.register you can forget. Fastify’s answer is encapsulation — each plugin is its own scope — and @fastify/autoload removes the manual registration entirely.

Drop this in and any file under src/routes or src/plugins loads itself.

TypeScript
// src/server.ts (add to the version above)
import autoload from "@fastify/autoload";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

const dir = fileURLToPath(new URL(".", import.meta.url));

app.register(autoload, { dir: join(dir, "plugins") });
app.register(autoload, { dir: join(dir, "routes"), options: { prefix: "/api" } });

The prefix option means every route file under routes/ is automatically namespaced under /api, so your books routes become /api/books without touching the route files. Each route file is a plugin:

TypeScript
// src/routes/books.ts
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod";

const books: FastifyPluginAsyncZod = async (app) => {
  // routes from the previous section go here; `app` already
  // carries the Zod type provider inherited from the parent
  app.get("/books", { schema: { /* ... */ } }, async () => {
    return bookService.list();
  });
};

export default books;

@fastify/autoload v6 is the version that works with Fastify v5, and it loads .ts files directly under tsx, which keeps your dev loop honest. The mental model that took me a while: a plugin only sees decorators and hooks registered in itself or its ancestors, never its siblings. That isolation is a feature — one route group can’t accidentally lean on another’s state.

A route group with params and a service layer

Putting database calls inside route handlers feels fast on day one and hurts by month three, when you want the same “find book by id” logic in three places and a test that doesn’t boot an HTTP server. The fix is a thin service layer the routes call into.

First, the service. Keep it framework-agnostic — no request, no reply, just data in and out. I’m using an in-memory store to stay focused; swap it for Prisma against Postgres when you’re ready (I cover that wiring in the Prisma + Postgres setup).

TypeScript
// src/services/book-service.ts
import { randomUUID } from "node:crypto";

export interface Book {
  id: string;
  title: string;
  author: string;
  year: number;
}

const books = new Map<string, Book>();

export const bookService = {
  list: async (): Promise<Book[]> => [...books.values()],
  get: async (id: string): Promise<Book | undefined> => books.get(id),
  create: async (data: Omit<Book, "id">): Promise<Book> => {
    const book = { id: randomUUID(), ...data };
    books.set(book.id, book);
    return book;
  },
};

Now a route with a typed URL parameter. The params schema types request.params.id for you, same as the body.

TypeScript
// inside src/routes/books.ts
app.get(
  "/books/:id",
  {
    schema: {
      params: z.object({ id: z.string().uuid() }),
      response: {
        200: z.object({
          id: z.string().uuid(),
          title: z.string(),
          author: z.string(),
          year: z.number(),
        }),
      },
    },
  },
  async (request, reply) => {
    const book = await bookService.get(request.params.id);
    if (!book) {
      return reply.code(404).send({ message: "Book not found" });
    }
    return book;
  },
);

Pass a non-UUID id and Fastify returns 400 before the service is even called. The handler stays about routing and status codes; the service owns the data. That seam is what makes both halves testable in isolation.

Error handling you set once

Scattering try/catch and reply.code(500) across twenty handlers guarantees inconsistent error shapes — a client never knows whether to read error, message, or msg. Fastify lets you define one handler for the whole app with setErrorHandler.

TypeScript
// src/plugins/error-handler.ts
import fp from "fastify-plugin";

export default fp(async (app) => {
  app.setErrorHandler((error, request, reply) => {
    // Validation failures Fastify already tagged
    if (error.validation) {
      return reply.code(400).send({
        error: "ValidationError",
        message: error.message,
      });
    }

    const status = error.statusCode ?? 500;
    if (status >= 500) {
      request.log.error(error); // log server faults, not client mistakes
    }

    return reply.code(status).send({
      error: error.name,
      message: status >= 500 ? "Internal Server Error" : error.message,
    });
  });
});

Two judgment calls live in there. I check error.validation first because Fastify attaches it to schema failures, and I’d rather format those myself than leak the raw default. And I never echo a 500’s real message back to the client — that’s how stack details and connection strings end up in someone’s browser. Log it server-side, send a flat string out. Because this file sits under src/plugins, autoload registers it before your routes, and fastify-plugin (fp) un-encapsulates it so the handler applies app-wide instead of being trapped in one scope.

Serialization speed, and why the response schema is doing work

Here’s the part Express never gave me for free. When you declare a response schema, Fastify compiles it once into a specialized serializer with fast-json-stringify instead of calling generic JSON.stringify on every reply. The generated function already knows your shape, so it skips the runtime type-sniffing JSON.stringify does field by field.

On that books list endpoint, adding the response schema was most of my 9k → 18k req/s jump on a 50-item payload. Your numbers will differ with payload size and machine, but the direction is reliable: the bigger and more repetitive the response, the more a compiled serializer pulls ahead. Fastify’s own benchmarks are why the framework leans on this so hard.

The response schema buys you a second thing that’s easy to miss: it strips any field not declared in the schema. Return a full user row that happens to carry a passwordHash, and if the schema doesn’t list it, it never reaches the wire.

TypeScript
const userOut = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  // no passwordHash here — so it's serialized away, every time
});

I treat that as the default safety net rather than something to remember per route. Write the output schema, and accidental leaks stop being a thing you police by hand.

Auth with a hook and @fastify/jwt

Checking a token at the top of every protected handler is repetitive and easy to skip on the one route that matters. Fastify hooks let you attach the check declaratively, per route or per group, so it’s impossible to forget.

@fastify/jwt v10 is the line that supports Fastify v5. Register it, then decorate the instance with an authenticate function that calls request.jwtVerify().

TypeScript
// src/plugins/auth.ts
import fp from "fastify-plugin";
import jwt from "@fastify/jwt";

declare module "fastify" {
  interface FastifyInstance {
    authenticate: (request: any, reply: any) => Promise<void>;
  }
}

export default fp(async (app) => {
  app.register(jwt, { secret: process.env.JWT_SECRET ?? "change-me-in-prod" });

  app.decorate("authenticate", async (request, reply) => {
    try {
      await request.jwtVerify();
    } catch (err) {
      reply.code(401).send({ error: "Unauthorized" });
    }
  });
});

Guard a route by putting the decorator in its onRequest hook:

TypeScript
app.post(
  "/books",
  {
    onRequest: [app.authenticate],
    schema: { body: createBookBody /* ... */ },
  },
  async (request, reply) => {
    /* only runs with a valid token; request.user holds the payload */
  },
);

Pull the secret from the environment, never a literal — the fallback above is a dev placeholder, and I’d make the app refuse to boot without JWT_SECRET set in production. For issuing tokens, hashing passwords, and refresh-token flow, I go end to end in JWT authentication in Node.js.

Where Fastify is the wrong tool

Fastify is my default, not my reflex. A few cases where I don’t reach for it:

  • A 40-line script or webhook receiver. If it’s one endpoint behind a serverless function or a tiny internal tool, the schema-and-plugin scaffolding is overhead you’ll never recoup. http.createServer or a five-line Express handler ships faster and reads fine.
  • A team standardized on Express. If your shop has Express middleware, hiring, and muscle memory built up over years, a unilateral switch buys you marginal throughput and a pile of retraining. Frameworks are a team decision, and consistency usually beats benchmarks.
  • An ecosystem gap you can’t fill. Most Express middleware has a Fastify plugin equivalent, but not all. If your app leans on a niche Express-only package with no port and no appetite to write one, that dependency outweighs the speed.

For a brand-new TypeScript API where you control the stack and care about types plus throughput, though, Fastify is hard to argue against. The schema-as-types loop alone has saved me more bugs than any linter.

FAQ

Does Fastify v5 work with TypeScript out of the box?

Yes. Fastify ships its own type definitions, so you don’t need an @types/fastify package. To get typed request.body, request.params, and request.query from your schemas, attach a type provider — fastify-type-provider-zod or @fastify/type-provider-typebox — with withTypeProvider. Without one, those properties stay typed as unknown and you’d fall back to manual generics on each route.

What Node.js version does Fastify v5 need?

Node 20 or newer. Fastify v5 dropped Node 18 to take advantage of newer platform features like the stable node:test runner, so on Node 18 or below you’ll either fail to install or hit runtime errors. Check node --version before you start, and if you’re on an older line, upgrade to Node 20 or 22 LTS first.

Should I use the Zod or TypeBox type provider?

Both give you typed handlers, so it’s mostly preference. I pick Zod when I want one library for runtime validation and types with a fluent, readable schema API — current fastify-type-provider-zod targets Zod 4. TypeBox sits closer to raw JSON Schema and tends to serialize a hair faster because there’s less translation. The rule that matters: choose one per codebase and don’t mix them, or every shape ends up described two ways.

Why is Fastify faster than Express for JSON APIs?

The biggest single reason is schema-based serialization. When you declare a response schema, Fastify compiles it with fast-json-stringify into a serializer specialized for that exact shape, which beats generic JSON.stringify — noticeably so on larger, repetitive payloads. Fastify also has a leaner routing and lifecycle core. Express stays competitive for simple, small responses, but the gap widens as payloads grow.

Do I have to use plugins and autoload, or can I keep one file?

A single file is perfectly valid for a small API, and Fastify won’t push you off it. Plugins and @fastify/autoload start paying off once you have several route groups and shared decorators, because encapsulation keeps scopes from leaking into each other and autoload removes the manual register calls you’d otherwise forget. Reach for them when the single file starts to ache, not before.

How does error handling work across all my routes?

Register one handler with setErrorHandler and Fastify routes every thrown or returned error through it, giving you a consistent response shape. Inside, check error.validation to catch schema failures and format them as 400s, read error.statusCode for the intended status, and crucially, don’t echo a 500’s raw message back to the client — log it server-side and return a flat string so stack traces and secrets stay out of responses.

Can I add JWT auth without a heavy framework?

Yes — @fastify/jwt v10 (the version for Fastify v5) plus a small hook covers it. Register the plugin with a secret from your environment, decorate the instance with an authenticate function that calls request.jwtVerify(), and attach it to any route’s onRequest hook. Protected routes get the check declaratively, so you can’t forget it on the one endpoint that matters, and the decoded payload lands on request.user.