# Fastify From Zero

> Learn the fast, schema-first Node.js framework: routing with JSON-schema validation and serialization, the encapsulated plugin system, the request/reply lifecycle and hooks, building a REST API, error handling, and testing and production. A modern, performance-focused alternative to Express that bakes in validation and structure.


---

# Fastify From Zero

Fastify is the Node framework you reach for when you want [Express](/guides/express-from-zero)'s simplicity
but more speed, more structure, and validation built in rather than bolted on. Two ideas set it apart:
it's genuinely **fast** (one of the quickest Node frameworks, partly because it compiles your JSON schemas
into optimized validation and serialization), and it's **schema-first** - you describe each route's input
and output with JSON Schema, and Fastify validates requests, serializes responses, and can generate docs
from that one description. It's a popular, production-proven choice, especially for new TypeScript services.

The mental model has two pillars. First, **a route is a handler plus a schema**: you give Fastify the
method, path, an `async (request, reply)` handler, and a `schema` describing body/params/querystring/response
 - and validation + fast serialization come for free. Second, **everything is a plugin**: routes, decorators,
and shared logic are registered as plugins, and plugins are **encapsulated** - what you register inside a
plugin is scoped to that plugin and its children, which is how Fastify keeps large apps organized. Hold
"routes carry schemas, and the app is a tree of encapsulated plugins," and Fastify makes sense.

> 📝 This teaches the **framework** - it assumes you know **JavaScript**/Node (functions, `async`/`await`,
> modules - [JavaScript From Zero](/guides/javascript-from-zero)); it's especially nice with
> [TypeScript](/guides/typescript-from-zero). It's most illuminating read against
> [Express](/guides/express-from-zero) (the closest comparison) and over the
> [node:http roots guide](/guides/build-a-server-with-node-http). Fastify runs on Node, so examples are
> shown with the commands to run them.

## How to read this

Read in order - it grows one service (a small **books API**) using schemas and plugins from a single route
to a tested, deployable REST API. Phases carry difficulty badges.

## The phases

**Part 1 - The core (🟢 → 🟡)**
1. **[What Fastify Is & Your First Server](01-what-fastify-is.md)** 🟢 - the instance, an async handler, and a running server.
2. **[Routing & Schemas](02-routing-and-schemas.md)** 🟡 - methods, params, and JSON-schema validation + serialization.
3. **[The Plugin System](03-the-plugin-system.md)** 🔴 - `register`, encapsulation, and `decorate`.

**Part 2 - A real API (🟡 → 🔴)**
4. **[Hooks & the Lifecycle](04-hooks-and-lifecycle.md)** 🟡 - the request/reply lifecycle and hooks (`onRequest`, `preHandler`, …).
5. **[Building a REST API](05-building-a-rest-api.md)** 🟡 - full CRUD with schemas, plugins, and a service.
6. **[Error Handling](06-error-handling.md)** 🔴 - `setErrorHandler`, validation errors, and consistent responses.

**Part 3 - Ship it (🟡 → 🟢)**
7. **[Testing & Production](07-testing-and-production.md)** 🟡 - `app.inject()`, logging, and deployment.
8. **[Where to Go Next](08-where-to-go-next.md)** 🟢 - Fastify vs Express/NestJS, the plugin ecosystem, and what to build.

> The throughline: **a route is a handler plus a schema, and the app is a tree of encapsulated plugins.**
> That schema-first, plugin-based design is Fastify's whole personality.


---

# What Fastify Is & Your First Server

You know [JavaScript](/guides/javascript-from-zero) - functions, `async`/`await`, modules - and you've
probably met [Express](/guides/express-from-zero), the minimalist Node web framework everyone starts
with. Fastify is what you reach for when Express's "everything's bolted on, figure it out yourself"
approach starts to bite. It's built around two promises: it's genuinely **fast** (one of the quickest
Node frameworks), and it's **schema-first** - input validation and JSON serialization are baked in, not
something you wire up later.

Like Express, Fastify sits on top of the same built-in HTTP server you'd otherwise drive raw
([the node:http guide](/guides/build-a-server-with-node-http) shows what's under there, worth seeing
once). If you've read [What a Framework Even Is](/guides/what-a-framework-even-is), Fastify is the
textbook *opinionated* framework: firm ideas about how you should structure things, in exchange for
speed and safety for free.

## The mental model: two pillars

Before any code, hold the two ideas that explain everything Fastify does. The rest of this guide is
just these two getting deeper.

💡 **A route is a handler plus a schema.** A handler is the function that runs when a request arrives.
A schema is a description (in JSON Schema) of what the request body, params, and response should look
like. Hand Fastify both and it validates incoming requests *and* serializes responses fast, for free.
You'll write handlers today; schemas arrive in [Phase 2](02-routing-and-schemas.md).

💡 **The app is a tree of plugins.** Everything you add - routes, shared logic, decorators - gets
registered as a **plugin**, and plugins nest inside other plugins. That tree is how Fastify keeps a
large app organized instead of one giant file. We build the trunk today; the branches come in
[Phase 3](03-the-plugin-system.md).

Hold those two - **handler + schema**, **tree of plugins** - and Fastify stops looking like magic.

## Your first server

One install gets you the framework:

```bash
npm install fastify
```

*What just happened:* `npm` downloaded Fastify and its dependencies into `node_modules` and recorded
it in your `package.json`. The whole framework is now available to `require` (or `import`). This
assumes you've run `npm init -y` first to create a `package.json` - if you haven't, do that, then
install.

Now the smallest Fastify server that does something real. Create a file called `index.js`:

```javascript
const Fastify = require('fastify');
const app = Fastify({ logger: true });

app.get('/', async (request, reply) => {
  return { hello: 'world' };       // returned value is sent as JSON
});

app.listen({ port: 3000 }, (err, address) => {
  if (err) { app.log.error(err); process.exit(1); }
  app.log.info(`listening on ${address}`);
});
```

*What just happened:* four moves, and they're the four you'll use forever.
- `const app = Fastify({ logger: true });` calls the Fastify function to create your **instance** - the
  object that holds your routes and runs the show. The `{ logger: true }` option turns on Fastify's
  built-in logger (the fast [pino](https://getpino.io) logger), so every request gets logged with no
  extra setup.
- `app.get('/', async (request, reply) => { ... })` **registers a route**: "when a `GET` arrives for
  `/`, run this handler." You never call the handler yourself - Fastify calls it when a matching
  request comes in. Notice it's an **`async` function** taking `(request, reply)`: the request (what
  the client sent) and the reply (how you respond).
- The handler **returns a value**, and Fastify sends it as the response. Return a plain object and
  Fastify serializes it to JSON and sets `Content-Type: application/json` for you - no `res.json()`
  ceremony. Whatever you `return` *is* the response body.
- `app.listen({ port: 3000 }, callback)` starts the underlying HTTP server on port 3000. The callback
  fires once it's up (or with an `err` if the port's taken); `address` is the URL it bound to.

⚠️ **Fastify's `listen` takes an options object - `{ port: 3000 }`, not bare `3000`.** This trips up
people coming from Express, where `app.listen(3000)` works. In Fastify 4 and 5 the first argument is
an options object (`{ port, host }`); passing a bare number is deprecated and will bite you. Get this
into your fingers early.

Run it with plain Node - Fastify is a library, there's no special CLI:

```bash
node index.js
```

```console
$ node index.js
{"level":30,"time":1718000000000,"pid":12345,"hostname":"laptop","msg":"Server listening at http://127.0.0.1:3000"}
{"level":30,"time":1718000000001,"pid":12345,"hostname":"laptop","msg":"listening on http://127.0.0.1:3000"}
```

*What just happened:* Node executed your file, Fastify handed its request handler to `node:http`, and
the server is listening. Those JSON lines are the built-in logger talking - the first is Fastify's own
startup message, the second is your `app.log.info` call. The server keeps running, waiting for
requests, until you stop it with `Ctrl+C`. Open a second terminal and hit it:

```bash
curl localhost:3000
```

```console
$ curl localhost:3000
{"hello":"world"}
```

*What just happened:* `curl` sent a `GET /`. Fastify matched it to your route, called the handler, took
its returned object, serialized it to JSON, and sent it back - and logged the request in your first
terminal as it did. A working JSON API in about seven lines.

## Returning a value vs. `reply.send()`

Returning a value is the idiomatic Fastify way, and it's enough most of the time. But sometimes you
need to control the **status code** or set headers - that's what the `reply` object is for. Compare:

```javascript
// Style 1: return the value - Fastify sends it as JSON with status 200
app.get('/health', async (request, reply) => {
  return { status: 'ok', uptime: process.uptime() };
});

// Style 2: use reply when you need a non-200 status or headers
app.post('/things', async (request, reply) => {
  reply.code(201).send({ created: true });
  // 201 = "Created" - the right status for a successful POST
});
```

*What just happened:* both routes respond with JSON. The first **returns** an object - the clean
default, status 200. The second calls `reply.code(201).send(obj)` to set a `201 Created` status *and*
send the body. Rule of thumb: **return when 200 is fine; reach for `reply` when you need a different
status or custom headers.** (One catch: if you call `reply.send()`, don't *also* return a value from
the same handler - pick one. Sending twice is an error, the same way it is in Express.)

The built-in logger you turned on with `{ logger: true }` is always available as `app.log` (and
per-request as `request.log`). It writes structured JSON - ugly to read raw, but exactly what log
tooling wants in production. For pretty local output you can pipe it through `pino-pretty`, but that's
a Phase 7 concern; for now, know that real logging is on by default.

## The running example: a books API

Across this guide we grow **one** real service so each concept lands on something concrete instead of a
toy. Meet the **books API** - a small catalog backend where each book is an object shaped like this:

```javascript
const books = [
  { id: 1, title: 'The Pragmatic Programmer', author: 'Hunt & Thomas' },
  { id: 2, title: 'Clean Code', author: 'Robert Martin' },
];

app.get('/books', async (request, reply) => {
  return books;
});
```

*What just happened:* `books` is an in-memory array of objects, each with `id`, `title`, and `author`.
The `GET /books` route returns the whole list; Fastify serializes the array to JSON. This is the first
endpoint of an API we'll turn into full create/read/update/delete (CRUD) - with schemas validating the
input and plugins organizing the code - over the coming phases. In-memory means the data resets every
restart; that's fine for learning, and we'll talk real storage later. Hitting it:

```console
$ curl localhost:3000/books
[{"id":1,"title":"The Pragmatic Programmer","author":"Hunt & Thomas"},{"id":2,"title":"Clean Code","author":"Robert Martin"}]
```

*What just happened:* the route returned the array, Fastify serialized it, and `curl` printed the JSON.
You've now seen the full shape of a Fastify endpoint - method, path, async handler, returned value - 
and you have a real API with one route. Next we make routes carry data (a book's `id` in the URL,
query strings) and attach **schemas** that validate it, which is
[Phase 2: Routing & Schemas](02-routing-and-schemas.md).

## Recap

1. **Fastify is the fast, schema-first Node web framework** - an [Express](/guides/express-from-zero)
   alternative that bakes in JSON-schema validation and fast serialization. It sits on top of
   `node:http`, like Express does. Install with `npm install fastify`.
2. The two big ideas: **a route is a handler plus a schema**, and **the app is a tree of plugins.**
   Schemas land in Phase 2, plugins in Phase 3 - today you write handlers and start the tree.
3. A first server is four moves: `Fastify({ logger: true })` creates the instance (with a built-in
   logger); `app.get(path, async (request, reply) => …)` registers a route; the handler's
   **returned value** is sent as JSON; `app.listen({ port })` starts it. Run with plain `node index.js`.
4. ⚠️ `app.listen` takes an **options object** (`{ port: 3000 }`), not a bare number - a common gotcha
   coming from Express.
5. **Return a value** for the clean 200 case; reach for **`reply.code(n).send(obj)`** when you need a
   specific status (like `201`) or custom headers. Don't return *and* send from the same handler.
6. Our running example is a **books API** (`{ id, title, author }`), starting from a single `GET /books`
   route and growing into full CRUD with schemas and plugins across the guide.

## Quick check

Three questions on what has to stick - what Fastify is, how a first server is wired, and how handlers
respond:

```quiz
[
  {
    "q": "What is Fastify, in one line?",
    "choices": [
      "A fast, schema-first Node.js web framework - an Express alternative with validation and JSON serialization built in",
      "A database for storing JSON documents in Node apps",
      "A standalone web server written in Rust that replaces Node entirely",
      "A frontend UI library for building components in the browser"
    ],
    "answer": 0,
    "explain": "Fastify is a fast, schema-first Node web framework. It's an Express alternative that bakes in JSON-schema validation and fast serialization, and like Express it runs on top of node:http rather than replacing Node."
  },
  {
    "q": "In a Fastify handler `async (request, reply) => { return { hello: 'world' }; }`, what happens to the returned object?",
    "choices": [
      "Fastify serializes it to JSON, sets Content-Type: application/json, and sends it as the response with status 200",
      "Nothing - you must call reply.json() yourself to send a response",
      "It's stored in memory and sent only on the next request",
      "It throws an error because handlers must not return a value"
    ],
    "answer": 0,
    "explain": "In Fastify, the value a handler returns becomes the response: a plain object is serialized to JSON with the JSON content type and sent with status 200. You only reach for reply (e.g. reply.code(201).send(obj)) when you need a different status or custom headers."
  },
  {
    "q": "Which call correctly starts a Fastify server on port 3000?",
    "choices": [
      "app.listen({ port: 3000 }, (err, address) => { ... })",
      "app.listen(3000, () => { ... })",
      "app.start(3000)",
      "app.serve({ on: 3000 })"
    ],
    "answer": 0,
    "explain": "Fastify's listen takes an options object - { port: 3000 } - not a bare number. Passing app.listen(3000) is the Express habit that's deprecated in Fastify and will bite you; always pass the options object."
  }
]
```


---

# Routing & Schemas

Here's the one idea that makes Fastify *Fastify*, and the thing to hold in your head for this whole phase:

> 📝 In Fastify, a route isn't only a handler. **A route is a handler PLUS a schema.** The handler is the code that runs; the schema is a description of what goes *in* (body, params, query) and what comes *out* (the response). Once you've written the schema, Fastify does two big chores for you - it **validates** incoming requests and it **serializes** outgoing responses - so you write less code, not more.

Most frameworks make you reach for a separate validation library and wire it up by hand in every handler. Fastify folds that into the route definition - describe the shape once, and the framework enforces it. That pays off the moment your API has more than three endpoints.

We'll keep growing the same **books API** from Phase 1 - a book is just `{ id, title, author }`.

## Routing basics

Fastify gives you a method per HTTP verb. The shape is the same one you saw in Phase 1:

```javascript
import Fastify from 'fastify';
const app = Fastify();

const books = [{ id: 1, title: 'Dune', author: 'Herbert' }];

// GET all books
app.get('/books', async () => books);

// GET one book by id  ->  /books/1
app.get('/books/:id', async (request) => {
  const id = Number(request.params.id);
  return books.find((b) => b.id === id);
});

// GET with a query string  ->  /books?author=Herbert
app.get('/books/search', async (request) => {
  const { author } = request.query;
  return books.filter((b) => b.author === author);
});

await app.listen({ port: 3000 });
```

*What just happened:* You declared three GET routes. The `:id` in the path is a **route parameter** - Fastify pulls it out and hands it to you on `request.params` (always as a string, which is why we `Number()` it). The `?author=...` part of the URL lands on `request.query`. Both are parsed for you; you only read them.

For routes that take a body - POST, PUT - Fastify reads `request.body`. Because the incoming `Content-Type` is `application/json`, Fastify parses the JSON automatically, so `request.body` is already a real object:

```javascript
app.post('/books', async (request, reply) => {
  const book = { id: books.length + 1, ...request.body };
  books.push(book);
  reply.code(201);
  return book;
});
```

*What just happened:* `request.body` is the parsed JSON the client sent. We built a new book, pushed it, set the status to `201 Created` with `reply.code(201)`, and returned the object - Fastify turns the returned value into the JSON response. Notice there's no `JSON.parse`, no `JSON.stringify`. That's all handled.

> 💡 There's also an options form of every route: `app.route({ method, url, schema, handler })`. The `app.get(...)`/`app.post(...)` shortcuts are sugar over it. You'll use the options form the moment you want to attach a `schema` - which is exactly what's next.

## Adding a schema to a route

So far that POST route trusts the client completely. If someone sends `{}` with no title, we happily store a broken book. The fix in most frameworks is hand-written `if (!request.body.title) ...` checks in every handler. In Fastify, you attach a **JSON Schema** instead and delete the checks entirely.

A schema is a plain JSON object describing shapes. You hang it on the route under `schema`, with keys for the parts you want to constrain - `body`, `params`, `querystring`, `headers`, and `response`:

```javascript
app.post('/books', {
  schema: {
    body: {
      type: 'object',
      required: ['title', 'author'],
      properties: {
        title: { type: 'string', minLength: 1 },
        author: { type: 'string' }
      }
    },
    response: {
      201: {
        type: 'object',
        properties: {
          id: { type: 'integer' },
          title: { type: 'string' },
          author: { type: 'string' }
        }
      }
    }
  }
}, async (request, reply) => {
  const book = { id: books.length + 1, ...request.body };
  books.push(book);
  reply.code(201);
  return book;
});
```

*What just happened:* The handler is unchanged - it still doesn't validate anything. The new `schema` object does the work. The `body` schema says "an object that must have `title` and `author`, and `title` must be at least one character." The `response` schema says "when you send a 201, it looks like this." Same handler as before, but now it's guarded.

> 💡 That one `schema` block is pulling **two** levers:
> 1. **Validation (input).** A request whose body violates the `body` schema never reaches your handler. Fastify rejects it with a `400 Bad Request` and a clear message like `body must have required property 'title'`. You wrote zero validation code.
> 2. **Serialization (output).** Fastify compiles your `response` schema into purpose-built code that's *faster* than the generic `JSON.stringify`, because it already knows the exact shape. Speed is a bonus; the real headline is the next section.

Send a bad request and you'll see the validation in action:

```javascript
// POST /books  with body  { "author": "Herbert" }   (no title)
//
// Fastify replies, before your handler runs:
// 400 Bad Request
// {
//   "statusCode": 400,
//   "error": "Bad Request",
//   "message": "body must have required property 'title'"
// }
```

*What just happened:* Nothing in your code ran. Fastify checked the body against the schema, found `title` missing, and short-circuited with a 400 and a human-readable message. This is the payoff: validation logic you didn't write and don't maintain.

## Reusing schemas with `addSchema`

Once you have several routes, the same shapes repeat - a `Book` here, an `id param` there. Instead of copy-pasting the JSON, register a schema with an `$id` once and point at it with `$ref`:

```javascript
app.addSchema({
  $id: 'book',
  type: 'object',
  properties: {
    id: { type: 'integer' },
    title: { type: 'string' },
    author: { type: 'string' }
  }
});

app.get('/books/:id', {
  schema: {
    response: { 200: { $ref: 'book#' } }
  }
}, async (request) => {
  const id = Number(request.params.id);
  return books.find((b) => b.id === id);
});
```

*What just happened:* `app.addSchema` filed the book shape under the id `'book'`. The route then references it with `{ $ref: 'book#' }` instead of repeating the properties. Define the shape once, reuse it across every route that touches a book. (Schemas are scoped to the plugin that registered them - more on plugins in the next phase.)

## The gotcha that catches everyone

This one surprises people, so read it twice:

> ⚠️ **The response schema FILTERS the output.** Fastify only sends the fields you *declared* in the response schema. Any property your handler returns that isn't listed is silently **stripped**. So if a field is mysteriously missing from your JSON response, don't debug your handler - check the response schema. The field is almost always just absent from the schema.

Here's the trap in miniature:

```javascript
app.get('/books/:id', {
  schema: {
    response: {
      200: {
        type: 'object',
        properties: {
          id: { type: 'integer' },
          title: { type: 'string' }
          // author is NOT listed here
        }
      }
    }
  }
}, async (request) => {
  const id = Number(request.params.id);
  return books.find((b) => b.id === id); // returns { id, title, author }
});
```

*What just happened:* The handler returns the full book including `author`. But the response schema only declares `id` and `title`, so the client receives `{ "id": 1, "title": "Dune" }` - `author` is gone. The handler did nothing wrong; the schema didn't invite `author` to the party. Add `author` to the response properties and it reappears.

This filtering is actually a feature once you expect it: it's a quiet security default. If you accidentally return a `passwordHash` or an internal field, an undeclared property won't leak - it gets stripped because it wasn't in the schema. The cost is the "where did my field go?" moment the first time it bites you. Now it won't.

## Recap

- A Fastify route is **a handler plus a schema** - the schema describes input and output so you write less code.
- Read input from `request.params` (path like `/books/:id`), `request.query` (the `?...` string), and `request.body` (auto-parsed JSON).
- Attaching a `schema` does two jobs: **validation** (bad input is auto-rejected with a `400` and a clear message - no validation code) and **fast serialization** of the response.
- Share repeated shapes with `app.addSchema({ $id, ... })` and reference them via `{ $ref: 'id#' }`.
- The **response schema filters output** to declared fields. Missing field in the response? Check the schema, not the handler - undeclared properties are stripped (which also keeps internal fields from leaking).

## Quick check

```quiz
[
  {
    "q": "A client POSTs a book with no `title`, and the route's `body` schema marks `title` as required. What happens?",
    "choices": ["The handler runs and must check for the missing title itself", "Fastify rejects the request with a 400 before the handler runs", "Fastify inserts an empty string for title and continues", "The server crashes with an unhandled error"],
    "answer": 1,
    "explain": "Schema validation runs before the handler. A body that violates the schema is auto-rejected with a 400 and a clear message, so the handler never sees invalid input."
  },
  {
    "q": "Your handler returns `{ id, title, author }`, but the response schema only lists `id` and `title`. What does the client receive?",
    "choices": ["{ id, title, author } - schemas don't affect output", "A 500 error because author isn't declared", "{ id, title } - author is stripped because it's not in the schema", "{ id, title, author: null }"],
    "answer": 2,
    "explain": "The response schema FILTERS output to declared properties. Undeclared fields like author are silently stripped - that's the #1 'where did my field go?' surprise."
  },
  {
    "q": "Where does Fastify put the value of `:id` from a path like `/books/:id`?",
    "choices": ["request.body.id", "request.query.id", "request.params.id", "request.id"],
    "answer": 2,
    "explain": "Route parameters from the path land on request.params (as strings). Query-string values go on request.query, and the parsed JSON body is request.body."
  }
]
```


---

# The Plugin System

Here's the one sentence that makes Fastify click: **your whole app is a tree of plugins.** Not "an app that *can use* plugins" - the app itself, every route, every shared bit of logic, is a plugin or lives inside one. Once you hold that picture, the things that confuse newcomers - why a decorator "disappears," why two halves of the app can't see each other - stop being mysteries and become the system working exactly as designed.

> 💡 The mental model for this whole phase: **a tree of little worlds.** Each plugin is a node. Each node gets its own scope - its own routes, hooks, and decorators - and by default that scope is sealed off from its siblings and its parent. You opt *out* of the sealing on purpose, not into it.

In the last two phases you wrote routes directly on `app`. That works for a toy. The moment you have a books section, a users section, and a shared database, you want them in separate files that don't trip over each other. That's what plugins give you.

## What a plugin actually is

A plugin is just a function. Two shapes are allowed:

```javascript
// async style (preferred - Fastify awaits it)
async function myPlugin(fastify, opts) {
  // register routes, hooks, decorators here
}

// callback style (call done() when finished)
function myPlugin(fastify, opts, done) {
  // ...
  done();
}
```

*What just happened:* Fastify handed your function a `fastify` instance and an `opts` object (whatever you passed at registration time). That `fastify` argument is **not** the same object as the top-level app - it's a *child* instance scoped to this plugin. Hold that thought; it's the whole reason encapsulation works.

## Writing a real plugin: book routes

Let's pull our books routes into their own plugin. Put this in `book-routes.js`:

```javascript
async function bookRoutes(fastify, opts) {
  fastify.get('/', async () => listBooks());

  fastify.get('/:id', async (req, reply) => {
    const book = getBook(req.params.id);
    if (!book) {
      reply.code(404);
      return { error: 'Not found' };
    }
    return book;
  });

  fastify.post('/', async (req, reply) => {
    reply.code(201);
    return createBook(req.body);
  });
}

module.exports = bookRoutes;
```

*What just happened:* nothing here registers on the global app - every `fastify.get`/`fastify.post` attaches to the *child* instance for this plugin. The routes don't exist anywhere until someone registers `bookRoutes`. Notice the paths are relative (`/`, `/:id`) - we'll give them a home in the next step.

## Mounting it with `register` and a `prefix`

Back in your main file, you bring the plugin into the tree with `app.register`:

```javascript
const Fastify = require('fastify');
const bookRoutes = require('./book-routes');

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

app.register(bookRoutes, { prefix: '/api/books' });

app.listen({ port: 3000 });
```

*What just happened:* `register` created a child instance, ran `bookRoutes` against it, and the `prefix: '/api/books'` option scoped every route inside the plugin to that path. So the plugin's `'/'` becomes `GET /api/books`, and `'/:id'` becomes `GET /api/books/:id`. The plugin doesn't know or care what prefix it lives under - you decide that at the mount point. That's reusability: register the same plugin twice under two prefixes and you get two mounted copies.

> 📝 `register` is **asynchronous** and deferred. Fastify doesn't run your plugin the instant you call `register` - it queues it and boots the whole tree when you call `listen` (or `ready`). If you ever need something to be fully wired up before continuing, `await app.ready()`.

## Encapsulation: the sealed-off scope

Here's the part that surprises people, so let's hit it head-on. Anything you register **inside** a plugin - routes, hooks, decorators, even sub-plugins - is visible to that plugin and its **children**, but invisible to its **siblings and its parent**.

Picture two plugins registered on the same app:

```javascript
app.register(async function adminArea(fastify) {
  fastify.addHook('onRequest', requireAdmin);   // auth hook
  fastify.get('/admin/stats', statsHandler);
});

app.register(async function publicArea(fastify) {
  fastify.get('/health', healthHandler);
});
```

*What just happened:* the `requireAdmin` hook is registered *inside* `adminArea`, so it guards `/admin/stats` - and **only** that subtree. It does not touch `/health`, because `publicArea` is a sibling, not a child. The auth check can't leak across. This is the feature that lets a 200-route app stay sane: each section's middleware, error handling, and decorations stay contained in its own little world. No "global middleware accidentally ran on the wrong route" bugs.

> 💡 Why this matters: in frameworks without encapsulation, registering middleware is a global act and order-of-registration becomes a minefield. In Fastify, scope is structural - where a thing lives in the tree *is* its reach.

## `decorate`: sharing things on the instance

You'll want shared resources - a database connection, a config object, a helper - available to handlers without importing them everywhere. That's `decorate`:

```javascript
const db = await connectToDatabase();

app.decorate('db', db);

app.get('/books/:id', async (req) => {
  return req.server.db.findBook(req.params.id);   // fastify.db, reachable via req.server
});
```

*What just happened:* `decorate('db', db)` hung a `db` property on the Fastify instance, so any handler in scope can reach it as `fastify.db` (and inside a handler, via `req.server.db` or by closing over `app`). There are two siblings for the request and reply objects:

```javascript
app.decorateRequest('user', null);   // adds req.user, default null
app.decorateReply('sendError', function (msg) {
  this.code(400).send({ error: msg });
});
```

*What just happened:* `decorateRequest` and `decorateReply` add properties/methods to every `request` and `reply`. Declaring `req.user` up front (with a default) is the right pattern - a hook later fills it in. And here's the catch that sets up the next section: **decorations follow the exact same encapsulation rules as everything else.** Decorate inside a plugin, and only that plugin's subtree sees it.

## The `fastify.db is undefined` trap - and `fp`

This is the single most common Fastify "why?!" moment, so let's make sure you never lose an hour to it.

You write a tidy database plugin:

```javascript
// db.js
async function dbPlugin(fastify, opts) {
  const conn = await connectToDatabase(opts.url);
  fastify.decorate('db', conn);
}

module.exports = dbPlugin;
```

Then you register it and try to use it from a sibling:

```javascript
app.register(dbPlugin, { url: process.env.DB_URL });
app.register(bookRoutes, { prefix: '/api/books' });

// inside bookRoutes: fastify.db  →  undefined  💥
```

*What just happened:* `dbPlugin` decorated its **own child instance** with `db`. By the encapsulation rules, that decoration is sealed inside `dbPlugin`'s scope. `bookRoutes` is a *sibling*, not a child - so it never sees `db`. Everything is behaving correctly; the connection is just trapped one level too deep.

⚠️ The fix is to **deliberately break encapsulation** for this one plugin using `fastify-plugin` (conventionally imported as `fp`). Wrapping a plugin in `fp` tells Fastify "don't create a sealed child scope for this - apply its decorations and hooks to the parent instead":

```javascript
// db.js
const fp = require('fastify-plugin');

async function dbPlugin(fastify, opts) {
  const conn = await connectToDatabase(opts.url);
  fastify.decorate('db', conn);
}

module.exports = fp(dbPlugin);   // ← decorations now reach the whole app
```

*What just happened:* `fp` removed the wall around `dbPlugin`. Now `decorate('db', conn)` lands on the parent app, so `bookRoutes` and every other sibling can reach `fastify.db`. This is the canonical use of `fastify-plugin`: shared infrastructure (database, auth, config) that the *whole* app legitimately needs.

> 💡 The decision rule: **leave a plugin encapsulated when it's a self-contained feature** (a routes section, an isolated subsystem). **Reach for `fp` when the plugin exists to share something app-wide.** Encapsulation is the default; `fp` is the explicit, intentional exception. If you wrap *everything* in `fp`, you've thrown away the one feature that keeps large Fastify apps from collapsing into spaghetti.

Here's the tree we just built, with the books API wired up properly:

```mermaid
flowchart TD
  app["app (root)"]
  db["dbPlugin - wrapped in fp\ndecorates the WHOLE tree with .db"]
  books["bookRoutes (encapsulated)\nprefix /api/books"]
  admin["adminArea (encapsulated)\nonRequest: requireAdmin"]
  app --> db
  app --> books
  app --> admin
  db -.->|.db now visible everywhere| books
  db -.->|.db now visible everywhere| admin
```

## Recap

- **Everything is a plugin.** Your app is a *tree* of plugins; `app.register(plugin, opts)` adds a node, and a plugin is just `async (fastify, opts) => {}` (or a callback form ending in `done()`).
- A `prefix` option on `register` scopes a plugin's routes under a path - the plugin stays unaware of where it's mounted, so it's reusable.
- **Encapsulation** is the core idea: routes, hooks, and decorators registered inside a plugin reach that plugin and its children only - never siblings or the parent. That's how big apps stay isolated.
- `decorate` adds shared things to the instance (`fastify.db`); `decorateRequest`/`decorateReply` add to every request/reply. All of them obey encapsulation.
- ⚠️ A decorator trapped in a plugin's scope is the classic `fastify.db is undefined` bug. Wrap the plugin in **`fastify-plugin` (`fp`)** to deliberately share across the whole app - use it for app-wide infrastructure, not for ordinary feature plugins.

## Quick check

```quiz
[
  {
    "q": "You register dbPlugin (it calls fastify.decorate('db', conn)) and, as a sibling, bookRoutes. Inside bookRoutes, fastify.db is undefined. Why?",
    "choices": [
      "decorate only works on request objects, not the instance",
      "The decoration is sealed inside dbPlugin's encapsulated scope, and bookRoutes is a sibling, not a child",
      "You must call decorate after listen()",
      "prefix erases decorations on registered plugins"
    ],
    "answer": 1,
    "explain": "Decorations follow encapsulation. dbPlugin decorated its own child instance, so a sibling plugin never sees it. Wrapping dbPlugin in fastify-plugin (fp) applies the decoration to the parent so the whole app can use it."
  },
  {
    "q": "What does the prefix option do in app.register(bookRoutes, { prefix: '/api/books' })?",
    "choices": [
      "Renames the plugin function",
      "Mounts every route inside bookRoutes under /api/books",
      "Makes the plugin's decorators global",
      "Delays the plugin until app.ready()"
    ],
    "answer": 1,
    "explain": "prefix scopes the plugin's routes to a path, so the plugin's '/' becomes GET /api/books. The plugin itself stays unaware of its mount point, which is what makes it reusable."
  },
  {
    "q": "When should you wrap a plugin in fastify-plugin (fp)?",
    "choices": [
      "Always - every plugin should use fp",
      "Never - fp is deprecated",
      "When the plugin provides something app-wide (db, auth, config) that siblings legitimately need to share",
      "Only for plugins that define routes"
    ],
    "answer": 2,
    "explain": "fp deliberately breaks encapsulation so a plugin's decorators/hooks reach the whole tree. Use it for shared infrastructure. Wrapping everything in fp throws away the isolation that keeps large apps maintainable."
  }
]
```


---

# Hooks & the Lifecycle

In [Phase 3](03-the-plugin-system.md) you learned that the app is a tree of encapsulated plugins. Now
we put that tree to work. This phase answers the question every real API hits sooner or later: *"I need
to run some code before my handler - check a token, log the request, set a header - but not copy-paste
it into every route. Where does that go?"*

## The mental model: a request flows a named lifecycle

Here's the idea to hold before any code. When a request arrives, Fastify doesn't jump straight to your
handler. It walks the request through a **fixed sequence of stages** - parse the body, validate it
against the schema, run the handler, serialize the response, send it. That sequence is the
**request/reply lifecycle**.

💡 A **hook** is a function you attach to one of those named stages. When the request reaches that
stage, your hook runs. That's Fastify's answer to what other frameworks call "middleware" - but instead
of one generic slot you cram everything into, you pick the *named* stage where your code belongs.

So "run code before the handler" isn't a vague instruction in Fastify. It's a specific stage with a
specific name (`preHandler`), and you hook into it.

## The lifecycle, stage by stage

Every request flows through these stages in order (failures jump to `onError`):

```
onRequest → preParsing → preValidation → preHandler → [your handler]
          → preSerialization → onSend → onResponse
```

You don't need to memorize all of them - most code only ever touches three or four. Here are the ones
that earn their keep:

- **`onRequest`** - the *earliest* stage. The body hasn't been parsed yet, so you can't read it, but
  the URL, method, and headers are all there. This is where logging and cheap auth checks (is there an
  `Authorization` header at all?) live. Rejecting here is the cheapest possible rejection.
- **`preValidation`** - runs *before* Fastify validates the request against your JSON schema. Handy when
  you need to massage incoming data before the schema judges it.
- **`preHandler`** - runs *after* validation, *right before* your handler. The request is fully parsed
  and validated by now, so this is the usual home for authorization that needs to look at the real,
  trustworthy request (the parsed body, validated params).
- **`onSend`** - runs as the response is about to go out, and it can *modify the payload* (add a header,
  rewrite the body).
- **`onResponse`** - runs *after* the response has been sent. Too late to change anything, which is
  exactly why it's perfect for metrics and timing.
- **`onError`** - runs when something throws. We'll lean on it in [Phase 6](06-error-handling.md).

📝 The shape to keep: `onRequest` is "I just arrived, body unknown"; `preHandler` is "I'm validated and
ready, last stop before the handler"; `onResponse` is "I'm already gone, just record it."

## Adding a hook with `addHook`

You attach a hook with `app.addHook(stageName, asyncFunction)`. The function receives the same
`request` and `reply` your handlers get:

```javascript
const Fastify = require('fastify');
const app = Fastify({ logger: true });

app.addHook('onRequest', async (request, reply) => {
  request.log.info({ url: request.url }, 'incoming request');
});

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

app.listen({ port: 3000 });
```

*What just happened:* we registered an `onRequest` hook on the whole app. Before *any* handler runs,
Fastify calls our function and logs the URL. Notice we didn't call a `next()` or `done()` - because the
hook is `async`, Fastify waits for the promise to resolve, then moves to the next stage on its own.
Hit `GET /health` and you'll see the log line print before the response.

## Short-circuiting: a `preHandler` auth hook

The real power of hooks is that they can *stop* a request. If a hook throws (or calls `reply.send`),
Fastify abandons the lifecycle right there - your handler never runs. That's how you build auth.

Let's guard our running **books API**. Back in [Phase 3](03-the-plugin-system.md) the books routes
lived in a `booksPlugin`. We add a `preHandler` hook *inside that plugin* that demands an
`Authorization` header:

```javascript
async function booksPlugin(app, opts) {
  // Runs before every handler in THIS plugin, after validation.
  app.addHook('preHandler', async (request, reply) => {
    if (!request.headers.authorization) {
      reply.code(401);
      throw new Error('Unauthorized');   // stops the request before the handler
    }
  });

  app.get('/books', async () => {
    return [{ id: 1, title: 'Dune' }];
  });

  app.post('/books', async (request) => {
    return { id: 2, ...request.body };
  });
}

module.exports = booksPlugin;
```

*What just happened:* every request to `/books` - `GET` or `POST` - now passes through the `preHandler`
hook first. If there's no `Authorization` header, we set the status to `401` and `throw`. The throw is
the short-circuit: Fastify catches it, the handler never runs, and the client gets a `401`. With a
header present, the hook returns normally and the request continues to the handler as usual. Because
this is `preHandler`, validation has already happened, so by the time auth runs you're looking at a
clean, validated request.

⚠️ Don't reach for `onRequest` to read the request body - at that stage it isn't parsed yet, so
`request.body` is `undefined`. Body-aware checks belong in `preHandler` or later.

## Encapsulation: the hook only guards its own plugin

Here's the payoff of plugins being encapsulated. That `preHandler` hook was added *inside*
`booksPlugin`, so it runs **only for routes registered inside `booksPlugin`** - and nowhere else. A
public `/health` route registered at the top level never sees it:

```javascript
const Fastify = require('fastify');
const app = Fastify();

app.register(require('./books-plugin'));   // /books - guarded by the auth hook

app.get('/health', async () => {           // top level - NOT guarded
  return { status: 'ok' };
});

app.listen({ port: 3000 });
```

*What just happened:* `/books` requires an `Authorization` header (the hook lives in the plugin that
owns those routes), while `/health` answers freely (it's registered outside that plugin's scope). You
protected one route group without touching the others, and without a single `if` inside your handlers.
This is the encapsulation rule from Phase 3 doing real work: **a hook added inside a plugin is scoped
to that plugin and its children.** Want a route group public? Register it outside the guarded plugin.
Want everything guarded? Add the hook at the root.

## Per-route hooks: guarding exactly one route

Sometimes you don't want a whole plugin guarded - just one route. Pass the hook in the route's options
instead of calling `addHook`:

```javascript
async function checkAuth(request, reply) {
  if (!request.headers.authorization) {
    reply.code(401);
    throw new Error('Unauthorized');
  }
}

app.post('/books', { preHandler: checkAuth }, async (request) => {
  return { id: 2, ...request.body };
});

app.get('/books', async () => {            // no preHandler - open to all
  return [{ id: 1, title: 'Dune' }];
});
```

*What just happened:* only `POST /books` runs `checkAuth`, because we listed it in *that route's*
options. The `GET /books` route, defined without a `preHandler`, stays open. Use `addHook` when a whole
plugin (or app) shares a hook; use the per-route option when a single route is the exception.

## Why named stages beat one generic slot

💡 If you've used [Express](/guides/express-from-zero), this is the moment the two frameworks diverge.
Express gives you *one* generic middleware signature - `(req, res, next)` - and you stack functions in
the order you happen to call `app.use`. It works, but the *meaning* of each function (auth? logging?
parsing?) lives only in your head and the call order. Get the order wrong and auth runs after the
handler.

Fastify replaces that single slot with **named lifecycle stages**. "Run before validation" and "run
after the response is sent" aren't conventions you enforce by ordering `app.use` calls - they're
distinct, named hooks (`preValidation`, `onResponse`) that *can't* run at the wrong time. That
explicitness is also what lets Fastify optimize: it knows exactly which stages a route uses and can
compile a tight path through them. Same idea as Express middleware, but the framework, not you, owns the
ordering.

## Recap

- A request flows a **fixed, named lifecycle**: `onRequest → preValidation → preHandler → handler →
  onSend → onResponse` (and `onError` on failure). **Hooks** let you run code at any stage.
- Add a hook with `app.addHook('stage', async (request, reply) => { ... })`. Async hooks need no
  `next()` - Fastify awaits the promise and moves on.
- A hook that **throws** (or calls `reply.send`) short-circuits the lifecycle: the handler never runs.
  That's how you build auth - `reply.code(401); throw` in a `preHandler`.
- Use **`onRequest`** for early/cheap checks (body not parsed yet) and **`preHandler`** for auth that
  needs the validated request; **`onResponse`** for metrics after the fact.
- Hooks respect **encapsulation**: a hook added inside a plugin guards only that plugin's routes - so
  you can protect one route group and leave `/health` public. For a single route, pass the hook in the
  route options instead.

## Quick check

```quiz
[
  {
    "q": "Which stage should an auth check that reads the parsed, validated request body run in?",
    "choices": ["onRequest", "preHandler", "onResponse", "onSend"],
    "answer": 1,
    "explain": "preHandler runs after validation and right before the handler, so the request is fully parsed and validated. onRequest is too early - the body isn't parsed yet."
  },
  {
    "q": "Inside a preHandler hook, what happens if you set reply.code(401) and then throw?",
    "choices": ["The handler still runs, then the error is logged", "Fastify retries the request", "The lifecycle short-circuits and the handler never runs", "Nothing - hooks can't change the response"],
    "answer": 2,
    "explain": "Throwing (or calling reply.send) in a hook abandons the lifecycle right there. The handler is skipped and the client gets the response you set."
  },
  {
    "q": "You add a preHandler hook with addHook INSIDE booksPlugin. Which routes does it run for?",
    "choices": ["Every route in the whole app", "Only routes registered inside booksPlugin and its children", "Only the first route in booksPlugin", "No routes until you call app.use"],
    "answer": 1,
    "explain": "Hooks respect plugin encapsulation. A hook added inside a plugin is scoped to that plugin and its children, so a top-level /health route stays unguarded."
  }
]
```


---

# Building a REST API

This is the payoff phase. Everything you've collected - routes carrying schemas (Phase 2), code organized into encapsulated plugins (Phase 3), and the request lifecycle with hooks (Phase 4) - clicks together here into one working CRUD service. Nothing new to learn here; this is assembly.

Here's the mental model to carry through the whole phase:

> 📝 A REST resource is **five schema-backed routes living in one plugin**, all operating over a single collection. List, read-one, create, update, delete - that's the shape of *every* resource in *every* framework. What makes it Fastify is the schema-first form: each route hangs a `schema` on itself, so the handlers shrink to almost nothing.

We've grown the **books API** since Phase 1. A book is `{ id, title, author }`. Now we'll give it the full set of operations and a proper home.

## The books plugin and its store

A resource belongs in its own plugin (Phase 3) - a self-contained feature, encapsulated, mounted under a prefix. Inside it we need somewhere to keep the books. For now that's an array and a counter:

```javascript
// book-routes.js
async function bookRoutes(fastify, opts) {
  const books = [{ id: 1, title: 'Dune', author: 'Herbert' }];
  let nextId = 2;

  // routes go here (next section)
}

module.exports = bookRoutes;
```

*What just happened:* we declared the plugin as `async (fastify, opts)` - the exact shape from Phase 3. The `books` array and `nextId` counter live *inside* the plugin function, so they're private to it; no other plugin can poke at the data directly. Reads and writes go through the routes. Because Node runs your JavaScript on a single thread, two requests never touch the array at the literal same instant - there are no locks to think about, no race conditions over `nextId`.

> 📝 This array is a stand-in for a database. It works perfectly in dev and vanishes the moment the process restarts. That's fine - it lets us focus on the *routes*, which are the part that stays the same when you swap in a real store.

## The five routes, each with a schema

Now the heart of it. Five routes, each carrying a `schema` (Phase 2) for validation and serialization. Notice how little code is in each handler - that's the whole point, and we'll name it after the block.

First, a small base schema for a book, so the response schemas don't repeat themselves:

```javascript
const bookSchema = {
  type: 'object',
  properties: {
    id: { type: 'integer' },
    title: { type: 'string' },
    author: { type: 'string' }
  }
};

const idParams = {
  type: 'object',
  properties: { id: { type: 'integer' } },
  required: ['id']
};

const bookBody = {
  type: 'object',
  required: ['title', 'author'],
  properties: {
    title: { type: 'string', minLength: 1 },
    author: { type: 'string', minLength: 1 }
  }
};
```

*What just happened:* we hoisted the three shapes we'll reuse - the book itself, the `:id` param, and the create/update body. `idParams` declares `id` as an `integer`; Fastify will coerce the string from the URL into a number for you and reject anything that isn't numeric. `bookBody` requires both fields, each non-empty. Defining these once keeps the routes readable.

Now the routes, all inside `bookRoutes`:

```javascript
// GET /  -> list every book
fastify.get('/', {
  schema: {
    response: { 200: { type: 'array', items: bookSchema } }
  }
}, async () => books);

// GET /:id  -> one book, or 404
fastify.get('/:id', {
  schema: {
    params: idParams,
    response: { 200: bookSchema }
  }
}, async (request, reply) => {
  const book = books.find((b) => b.id === request.params.id);
  if (!book) {
    reply.code(404);
    return { error: 'Book not found' };
  }
  return book;
});

// POST /  -> create
fastify.post('/', {
  schema: {
    body: bookBody,
    response: { 201: bookSchema }
  }
}, async (request, reply) => {
  const book = { id: nextId++, ...request.body };
  books.push(book);
  reply.code(201);
  return book;
});

// PUT /:id  -> update, or 404
fastify.put('/:id', {
  schema: {
    params: idParams,
    body: bookBody,
    response: { 200: bookSchema }
  }
}, async (request, reply) => {
  const book = books.find((b) => b.id === request.params.id);
  if (!book) {
    reply.code(404);
    return { error: 'Book not found' };
  }
  book.title = request.body.title;
  book.author = request.body.author;
  return book;
});

// DELETE /:id  -> remove, or 404
fastify.delete('/:id', {
  schema: {
    params: idParams
  }
}, async (request, reply) => {
  const index = books.findIndex((b) => b.id === request.params.id);
  if (index === -1) {
    reply.code(404);
    return { error: 'Book not found' };
  }
  books.splice(index, 1);
  reply.code(204);
  return null;
});
```

*What just happened:* the full CRUD surface, five routes. `GET /` returns the array (response schema says it's an array of books). `GET /:id` and `PUT /:id` look up by id and either return the book or set `404` and return an error object. `POST /` builds a book with the next id, pushes it, and sends `201 Created`. `DELETE /:id` removes the book and sends `204 No Content` with no body - that's why it returns `null`. Each route declares only the schema parts it actually uses: a list needs no `body`, a delete needs no `response` shape.

> 💡 Look at how *thin* the handlers are. Not one line of `if (!request.body.title)`, no type-checking, no "is this id even a number?" None of it. That work moved into the `schema`, where Fastify does it before your handler ever runs. Bad input is auto-rejected with a `400` (Phase 2) up front, so by the time your code executes, the input is already known-good. Schema-first routing is what makes these handlers small enough to read at a glance.

Finally, mount the plugin under a prefix in your main file (Phase 3):

```javascript
// server.js
const Fastify = require('fastify');
const bookRoutes = require('./book-routes');

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

app.register(bookRoutes, { prefix: '/api/books' });

app.listen({ port: 3000 });
```

*What just happened:* `register` mounts the whole resource under `/api/books`, so the plugin's `'/'` becomes `GET /api/books`, its `'/:id'` becomes `GET /api/books/:id`, and so on. The plugin stays unaware of where it lives; the prefix is decided here, at the mount point.

## Driving it from the command line

Let's exercise every route with `curl` and watch the responses. Start the server first, then in another terminal:

```bash
# List all books
curl http://localhost:3000/api/books
# -> [{"id":1,"title":"Dune","author":"Herbert"}]

# Get one book
curl http://localhost:3000/api/books/1
# -> {"id":1,"title":"Dune","author":"Herbert"}

# Get a missing book -> 404
curl -i http://localhost:3000/api/books/999
# -> HTTP/1.1 404 Not Found
# -> {"error":"Book not found"}

# Create a book -> 201
curl -i -X POST http://localhost:3000/api/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"Neuromancer","author":"Gibson"}'
# -> HTTP/1.1 201 Created
# -> {"id":2,"title":"Neuromancer","author":"Gibson"}

# Update it -> 200
curl -X PUT http://localhost:3000/api/books/2 \
  -H 'Content-Type: application/json' \
  -d '{"title":"Neuromancer","author":"William Gibson"}'
# -> {"id":2,"title":"Neuromancer","author":"William Gibson"}

# Delete it -> 204 (no body)
curl -i -X DELETE http://localhost:3000/api/books/2
# -> HTTP/1.1 204 No Content
```

*What just happened:* the five routes, walked end to end. The `-i` flag prints the status line so you can see `201`, `404`, and `204` for yourself. The 204 returns no body at all - that's the correct "done, nothing to send back" answer for a delete.

Now the interesting one - send a body that breaks the schema, and watch Fastify reject it before your handler runs:

```bash
# Create with no title -> 400, auto-generated by the schema
curl -i -X POST http://localhost:3000/api/books \
  -H 'Content-Type: application/json' \
  -d '{"author":"Gibson"}'
# -> HTTP/1.1 400 Bad Request
# -> {
# ->   "statusCode": 400,
# ->   "error": "Bad Request",
# ->   "message": "body must have required property 'title'"
# -> }
```

*What just happened:* nothing in `bookRoutes` executed. The `bookBody` schema marks `title` required; the request omitted it; Fastify short-circuited with a `400` and a human-readable message. You wrote zero lines to produce this - it fell out of the schema you'd already declared for serialization. That's validation and structure for free.

## Where this goes next

Two threads to pull on, both pointing forward.

> 💡 That `books` array is a database with the lifespan of a process. When you're ready for data that survives a restart, you swap the array for a real store - most likely through an ORM, which maps your `{ id, title, author }` objects to rows in a table (see [how an ORM works](/guides/how-an-orm-works)). Here's the good news: the **routes don't change shape**. `GET /:id` still finds-or-404s; `POST /` still creates-and-201s. Only the four lines that touch `books` become calls into the store. The schema-backed skeleton is the durable part.

> 💡 You probably noticed the same `if (!book) { reply.code(404); return { error: '...' } }` block copy-pasted across three routes. That repetition is a smell, and it's exactly what **Phase 6** fixes: a centralized error handler (`setErrorHandler`) and a single way to signal "not found," so the handlers stop repeating themselves and the error responses stay consistent across the whole API.

## Recap

- A REST resource is **five schema-backed routes in one plugin** over a single collection - the same shape in every framework, here in Fastify's schema-first form.
- Put the resource in its own encapsulated plugin (Phase 3) with a private in-memory store; Node's single thread means no locks and no race over `nextId`.
- The five routes map to verbs and status codes: `GET /` (200 list), `GET /:id` (200 or 404), `POST /` (201), `PUT /:id` (200 or 404), `DELETE /:id` (204 or 404).
- Handlers stay tiny because validation lives in the `schema` (Phase 2): bad input is auto-rejected with a `400` before your code runs - no hand-written checks.
- The array is a database stand-in - swap it for a real store via an [ORM](/guides/how-an-orm-works) and the routes keep their shape; the repeated 404 block gets centralized in Phase 6.

## Quick check

```quiz
[
  {
    "q": "Why are the CRUD handlers so short - almost no validation code inside them?",
    "choices": ["Fastify hides the validation in a separate file it generates", "The schema attached to each route validates input before the handler runs, so bad input never reaches it", "Validation only runs in production builds", "request.body is pre-validated by Node, not Fastify"],
    "answer": 1,
    "explain": "Each route's schema does validation (and serialization). A request that violates the body/params schema is auto-rejected with a 400 before the handler executes, so the handler only ever sees known-good input - no manual checks needed."
  },
  {
    "q": "What status code should DELETE /:id return on a successful delete, and what body?",
    "choices": ["200 with the deleted book", "201 with no body", "204 with no body", "404 with an error object"],
    "answer": 2,
    "explain": "A successful delete returns 204 No Content with no body - there's nothing meaningful to send back. The handler sets reply.code(204) and returns null. (404 is for when the book isn't found.)"
  },
  {
    "q": "You want to replace the in-memory `books` array with a real database later. What happens to the five routes?",
    "choices": ["They must be rewritten from scratch with new schemas", "They keep their shape - only the few lines touching `books` become store calls", "Each route needs its own database connection plugin", "The schemas have to be removed because the DB validates instead"],
    "answer": 1,
    "explain": "The schema-backed route skeleton is the durable part. Swapping the array for a store (typically via an ORM) only changes the handful of lines that read/write `books`; the routing, status codes, and schemas stay the same."
  }
]
```


---

# Error Handling

Here's the reframe that makes this whole phase click, so hold it before you write a single line of error code:

> 📝 In Fastify, **error handling is mostly something you DON'T do**. A request that fails your route schema is rejected for you with a `400`. An error thrown inside an `async` handler is caught for you and routed to one place. Your job isn't to wrap everything in `try/catch` - it's to **throw the right error** and then **decide, in one spot, how errors become responses**. The framework does the catching; you do the shaping.

If you came from [Express](/guides/express-from-zero), this is a genuine relief. There, an unhandled throw in an async route handler in Express 4 silently hangs the request unless you wired up `express-async-errors` or hand-passed errors to `next(err)`. Fastify removes that whole category of bug. We'll keep growing the same **books API**.

## The wins you already have for free

Before customizing anything, take inventory of what Fastify already does. Two big ones.

**Schema validation rejects bad input for you (a 400, no code).** You saw this back in [Routing & Schemas](02-routing-and-schemas.md): a body that violates the route's `body` schema never reaches your handler. Fastify short-circuits with a `400` and a structured message.

```javascript
// POST /books  with body  { "author": "Herbert" }   (no title)
//
// Fastify replies BEFORE your handler runs:
// 400 Bad Request
// {
//   "statusCode": 400,
//   "error": "Bad Request",
//   "message": "body must have required property 'title'"
// }
```

*What just happened:* Nothing of yours executed. The schema said `title` is required, the body lacked it, and Fastify produced a 400 with a clear message on its own. That's error handling you didn't write and don't maintain - the cheapest kind.

**Async throws are caught automatically.** In an `async` handler, you can just `throw`, and Fastify catches it and turns it into a response. Compare the two worlds:

```javascript
// Express 4: this throw is NOT caught - the request hangs.
app.get('/books/:id', async (req, res) => {
  const book = await db.find(req.params.id);
  if (!book) throw new Error('not found'); // request stalls; needs next(err) or a wrapper
  res.json(book);
});

// Fastify: this throw IS caught - it flows to the error handler.
app.get('/books/:id', async (request) => {
  const book = await db.find(request.params.id);
  if (!book) throw new Error('not found'); // caught for you → goes to setErrorHandler
  return book;
});
```

*What just happened:* Same logic, different safety net. Fastify wraps your async handler so a thrown error becomes a forwarded error rather than a hung socket. That's why, in Fastify, **throwing is the idiomatic way to bail out** of a handler - you don't need `try/catch` around your own logic just to send an error response.

## Shaping errors with `setErrorHandler`

Everything that gets thrown or forwarded - your throws, downstream plugin errors, validation failures - funnels into one function you can define: `setErrorHandler`. This is where you decide what an error *looks like* to the client.

```javascript
app.setErrorHandler((error, request, reply) => {
  request.log.error(error);
  const status = error.statusCode || 500;
  reply.code(status).send({ error: error.message || 'Internal Server Error' });
});
```

*What just happened:* Every error that reaches Fastify now passes through here. We log it with the request-scoped logger (so the log line carries the request id - handy in production), read a `statusCode` off the error if it has one (defaulting to `500`), and send a small, consistent body. From now on, every error response in the app has the same shape. That consistency is the entire point - clients shouldn't have to guess whether an error is `{ error }` or `{ message }` or `{ msg }` depending on which route blew up.

Notice the lever that makes this work: **`error.statusCode`**. Fastify reads it to set the HTTP status. So the way you control *which* status a thrown error produces is by putting a `statusCode` on the error before you throw it:

```javascript
app.get('/books/:id', async (request) => {
  const id = Number(request.params.id);
  const book = books.find((b) => b.id === id);
  if (!book) {
    const err = new Error('Book not found');
    err.statusCode = 404;
    throw err;
  }
  return book;
});
```

*What just happened:* We built a plain `Error`, tagged it with `statusCode = 404`, and threw it. Fastify caught it, ran it through `setErrorHandler`, which read `error.statusCode` and replied `404 { "error": "Book not found" }`. No `reply.code(404)` scattered in the handler - the handler just states "this is a 404 situation" and the central handler renders it.

## Typed errors without the boilerplate: `@fastify/sensible`

Hand-stamping `err.statusCode = 404` on every error gets old. The official `@fastify/sensible` plugin gives you a set of ready-made HTTP error throwers under `app.httpErrors`, so you don't construct errors by hand.

```javascript
import sensible from '@fastify/sensible';

await app.register(sensible);

app.get('/books/:id', async (request) => {
  const id = Number(request.params.id);
  const book = books.find((b) => b.id === id);
  if (!book) throw app.httpErrors.notFound('Book not found'); // a 404, fully formed
  return book;
});
```

*What just happened:* `app.httpErrors.notFound('Book not found')` returns an error object that already carries `statusCode: 404` and the right message, so throwing it produces a clean 404. There are throwers for the whole family - `badRequest`, `unauthorized`, `forbidden`, `conflict`, `unprocessableEntity`, and so on. It reads like the intent (`throw app.httpErrors.conflict('that ISBN already exists')`) instead of error plumbing.

> 💡 This is the rhythm to internalize: **throw typed errors from your handlers, let schema validation throw for you on bad input, and let one `setErrorHandler` render all of it.** Your handlers stay focused on the happy path and `throw` to bail; the shape of every error response lives in exactly one place.

## The 404 for unknown routes: `setNotFoundHandler`

There's one error the error handler does *not* catch by default: a request to a route that doesn't exist at all (say `DELETE /widgets` when you have no widgets route). That's not a thrown error - there's no handler to run. Fastify replies with its own default 404. To make that 404 match the rest of your API, set a not-found handler:

```javascript
app.setNotFoundHandler((request, reply) => {
  reply.code(404).send({ error: 'Not Found' });
});
```

*What just happened:* Now an unmatched URL returns *your* `{ "error": "Not Found" }` body with a 404, consistent with what `setErrorHandler` produces for thrown errors. Two different doors (no-such-route vs. error-while-handling), but the client sees one consistent house style.

## Reshaping validation errors

By default a schema-validation failure produces `{ statusCode, error, message }`. That's fine for most APIs, but sometimes you want your own envelope - say, a `fields` array a frontend can map to form inputs. You don't disable validation to do this; you reshape it *inside* `setErrorHandler` by checking `error.validation`.

When Fastify rejects a request for schema reasons, the error it forwards carries a `validation` property: an array of the individual schema violations. Branch on it:

```javascript
app.setErrorHandler((error, request, reply) => {
  if (error.validation) {
    // schema-validation failure → return our own shape
    return reply.code(400).send({
      error: 'Validation failed',
      fields: error.validation.map((v) => v.instancePath || v.params.missingProperty)
    });
  }
  // everything else → the generic path
  request.log.error(error);
  const status = error.statusCode || 500;
  reply.code(status).send({ error: error.message || 'Internal Server Error' });
});
```

*What just happened:* The same central handler now has two branches. When `error.validation` is present, we know this came from schema validation and we emit our custom `{ error, fields }` shape with a 400. Everything else falls through to the generic logging-and-status path from before. Validation still runs automatically and still rejects bad input before your handler - we only changed how that rejection is *presented*.

> ⚠️ `error.validation` exists **only** on errors that come from schema validation. Don't read `error.validation.map(...)` unconditionally - on a regular thrown error it's `undefined` and you'll crash your own error handler (the one place you really don't want to throw). Always gate it behind the `if (error.validation)` check, as above.

## Recap

- Most error handling in Fastify is **automatic**: schema validation rejects bad input with a `400` you didn't write, and `async` throws are caught for you (unlike bare Express 4) and forwarded.
- **Throwing is the idiomatic way to bail** out of a handler - no `try/catch` needed just to send an error response.
- `setErrorHandler` is the **one place** all thrown/forwarded errors become responses; it reads `error.statusCode` to set the HTTP status, so give your errors a `statusCode`.
- `@fastify/sensible` gives you `app.httpErrors.notFound(...)` and friends - typed errors with the right `statusCode` baked in, no hand-stamping.
- `setNotFoundHandler` customizes the 404 for **unmatched routes** (a separate door from `setErrorHandler`); reshape **validation** responses by checking `error.validation` inside `setErrorHandler`.

## Quick check

```quiz
[
  {
    "q": "A handler does `throw app.httpErrors.notFound('Book not found')`. How does that become a 404 response?",
    "choices": ["You must also call reply.code(404) in the handler", "Fastify catches the throw and setErrorHandler reads error.statusCode (404) to set the status", "@fastify/sensible sends the response directly, bypassing the error handler", "It returns a 500 unless you wrap the handler in try/catch"],
    "answer": 1,
    "explain": "httpErrors.notFound() returns an error already carrying statusCode 404. Fastify catches the async throw and routes it to setErrorHandler, which reads error.statusCode to set the HTTP status."
  },
  {
    "q": "Inside setErrorHandler, what does the presence of `error.validation` tell you?",
    "choices": ["The error came from schema validation, and validation holds the list of violations", "The response was successfully validated against the response schema", "Validation is disabled for this route", "The error has no statusCode and must be a 500"],
    "answer": 0,
    "explain": "Schema-validation failures forward an error with a `validation` array of the individual violations. Gate any custom reshaping behind `if (error.validation)` - it's undefined on ordinary thrown errors."
  },
  {
    "q": "A request hits `DELETE /widgets`, a route you never defined. Which handler shapes that response?",
    "choices": ["setErrorHandler, because every error goes through it", "Neither - Fastify always returns its built-in 404", "setNotFoundHandler, because no route matched (it isn't a thrown error)", "The body schema's validation handler"],
    "answer": 2,
    "explain": "An unmatched route isn't a thrown error, so setErrorHandler doesn't see it. setNotFoundHandler customizes the 404 for routes that don't exist."
  }
]
```


---

# Testing & Production

Here's the mental model that makes Fastify testing click: **a test sends a fake request through your real app, in the same process - no port, no socket, no network.** You call `app.inject()`, hand it a method and a URL, and Fastify dispatches that request through the *entire* app - every hook, every schema, every plugin, your route handler - exactly as a real HTTP request would travel. Then it hands you back the response object to assert on.

> 💡 Coming from Express, you reached for `supertest`. In Fastify you don't install anything: `app.inject()` is built in, and it's the official, recommended way to test. One less dependency, and it understands Fastify's lifecycle natively.

The reason this works cleanly is a small structural choice we make once and reuse everywhere: **build the app as a function that returns a configured-but-not-started instance.** Your tests build a fresh app and inject into it; your entry file builds the same app and calls `listen` on it. The two share one definition and never fight over a port.

## Structure: a `buildApp()` function

Everything in this phase depends on splitting "build the app" from "start the app." Put the building in a function that registers your plugins and routes and returns the instance - but never calls `listen`.

```javascript
// app.js
const Fastify = require('fastify');

function buildApp(opts = {}) {
  const app = Fastify(opts);

  app.register(require('./routes/books'));   // your CRUD plugin from Phase 5
  // ...setErrorHandler, other plugins, decorators...

  return app;                                 // configured, NOT listening
}

module.exports = buildApp;
```

*What just happened:* `buildApp()` does all the wiring - it creates a Fastify instance, registers the books routes plugin, and returns it. The crucial thing is what it *doesn't* do: it never calls `app.listen()`, so nothing binds a port. The `opts` parameter lets a caller pass options through (a test might pass `{ logger: false }` to keep output quiet; production passes `{ logger: true }`). This one function is now the single source of truth for what your app *is*.

The entry file is then tiny - it builds the app and starts it:

```javascript
// server.js
const buildApp = require('./app');

const app = buildApp({ logger: true });
const port = process.env.PORT || 3000;

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

*What just happened:* `server.js` is the only place that calls `listen`. It builds the app with logging on, reads the port from the environment (more on that below), and starts accepting connections. If startup fails - a port already in use, a plugin that throws - we log the error and exit non-zero so a supervisor knows the process died. Tests will `require('./app')`, never `./server.js`, so importing your app for a test never tries to open a socket.

## Testing the books API with `app.inject()`

Now the payoff. A test builds the app, injects a request, asserts on the response, and closes the app. The runner can be `jest`, `vitest`, or Node's built-in `node:test` - `app.inject()` is identical for all of them.

```javascript
const buildApp = require('../app');

test('GET /api/books returns 200', async () => {
  const app = buildApp();
  const res = await app.inject({ method: 'GET', url: '/api/books' });

  expect(res.statusCode).toBe(200);
  expect(res.json()).toBeInstanceOf(Array);   // res.json() parses the body
  await app.close();
});
```

*What just happened:* `buildApp()` gives us a fresh instance for this test. `app.inject({ method, url })` constructs a fake GET request and pushes it through the full lifecycle - `onRequest` hooks, schema validation, your handler - without ever touching TCP. `await` resolves once your route replies, and `res` holds the status, headers, and body. `res.json()` is a Fastify convenience that parses the JSON body for you, so we can assert it came back as an array. Finally `await app.close()` runs your `onClose` hooks and frees resources - important so one test's app doesn't leak into the next.

Writes look the same, with a `payload` for the body:

```javascript
test('POST /api/books creates a book', async () => {
  const app = buildApp();
  const res = await app.inject({
    method: 'POST',
    url: '/api/books',
    payload: { title: 'Dune', author: 'Herbert' },
  });

  expect(res.statusCode).toBe(201);
  expect(res.json().title).toBe('Dune');
  await app.close();
});
```

*What just happened:* `payload` is the request body. Hand it a plain object and Fastify serializes it to JSON and sets `Content-Type: application/json` for you, so your route's body schema validates it just like a real client's POST. We assert the 201 and that the created book came back with the right title. No port, no `fetch`, no server running in the background - just a function call that exercises your real route.

> 📝 Test the unhappy paths, not only the happy ones. A `POST` missing `title` should return `400` (your Phase 2 body schema rejects it), and `GET /api/books/9999` for a missing id should return `404` (your Phase 6 error handling). Those tests are where validation and error handling earn their keep - and where regressions hide. The same `app.inject()` tests become your **regression net in CI**: every push runs them before anything merges. See [Testing in CI](/guides/testing-in-ci) for wiring this into a pipeline.

> ⚠️ Shared mutable state will bite you. If the books API keeps books in an in-memory array, one test's `POST` leaks into the next test's `GET`, and your suite passes alone but fails together. Build a fresh app per test (or reset the store in a `beforeEach`) so tests stay independent. Order-dependent tests are a classic, maddening flake.

## Getting ready for production

A server that runs on your laptop isn't a server ready for the open internet. Three concerns separate them: structured logging, configuration that changes per environment, and shutdown behavior that doesn't drop requests. Fastify gives you strong defaults for the first.

**Logging is built in - turn it on.** Fastify ships with [pino](/guides/express-from-zero), a very fast JSON logger, wired straight into the instance.

```javascript
const app = buildApp({
  logger: {
    level: process.env.LOG_LEVEL || 'info',
    redact: ['req.headers.authorization'],   // never log secrets
  },
});

app.get('/api/books/:id', async (request, reply) => {
  request.log.info({ id: request.params.id }, 'fetching book');
  // ...
});
```

*What just happened:* passing a `logger` object configures pino - `level` controls verbosity (set it from the environment so prod can be quieter or louder without a code change), and `redact` scrubs sensitive fields so an auth token never lands in your logs. Fastify automatically logs every request and response with timing, and `request.log` is a child logger that tags each line with that request's id - so when you add `request.log.info(...)`, you get structured, correlated logs you can actually search in production. Structured JSON beats `console.log` the moment you ship.

**Config comes from the environment, never hardcoded.** Port, log level, database URL - all of it lives in environment variables so the same code runs unchanged on your machine, in CI, and in production.

```javascript
const port = process.env.PORT || 3000;
const host = process.env.HOST || '0.0.0.0';
```

*What just happened:* `process.env.PORT` reads the port your platform assigns (most set it for you), with a local fallback. The `host` matters more than it looks: the default `localhost` only accepts connections from the same machine, which is invisible from outside a **container**. Listening on `0.0.0.0` binds all interfaces so the container's port mapping (or your orchestrator) can actually reach the app. Forgetting this is the classic "works locally, 502s in Docker" bug.

## Shutting down without dropping requests

When a platform redeploys or scales down, it sends your process a `SIGTERM` and gives it a few seconds before it's killed. Ignore the signal and in-flight requests get cut mid-response. **Graceful shutdown means: stop accepting new connections, let current ones finish, then exit.** Fastify's `app.close()` does exactly the draining for you.

```javascript
async function shutdown() {
  app.log.info('shutting down...');
  await app.close();          // drains in-flight requests, runs onClose hooks
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);   // Ctrl-C in your terminal
```

*What just happened:* on `SIGTERM` (deploy/scale-down) or `SIGINT` (you hit Ctrl-C), we call `await app.close()`. Fastify stops accepting new connections, lets the requests already in flight run to completion, and runs every plugin's `onClose` hook (closing DB pools, flushing logs) before the promise resolves. Only then do we `process.exit(0)`. No half-written responses, no abandoned clients, no leaked connections.

> 💡 If you'd rather not hand-write the signal handling, the official `@fastify/graceful-shutdown` plugin registers these handlers and the drain logic for you. The small handler above is fine for most apps; reach for the plugin when you want extra timeout control.

**Stack the official hardening plugins** the same way you registered everything else - Fastify keeps the core lean and ships security and limits as first-party plugins:

```javascript
app.register(require('@fastify/helmet'));    // safe HTTP security headers
app.register(require('@fastify/cors'));      // controls cross-origin access
app.register(require('@fastify/rate-limit'), { max: 100, timeWindow: '1 minute' });
```

*What just happened:* each `register` adds a first-party plugin. `@fastify/helmet` sets defensive HTTP headers, `@fastify/cors` decides which browser origins may call your API, and `@fastify/rate-limit` rejects an IP that exceeds 100 requests a minute, blunting abuse. These are official, Fastify-maintained packages - the framework's whole philosophy is a fast core plus plugins for everything else, and production hardening is just three more registrations.

## Deploying it

With logging, config, graceful shutdown, and hardening in place, deployment is mostly packaging:

- **Run it in a container, behind a reverse proxy.** In production your app almost always sits behind nginx or your platform's load balancer, which terminates TLS and forwards requests. Bind to `0.0.0.0` (above) so the proxy can reach it, and configure `trustProxy` in your Fastify options if you need the real client IP from `X-Forwarded-For`.
- **Use `fastify-cli` as a convenient runner.** Instead of a hand-written `server.js`, `fastify-cli` can start your app from the command line: `fastify start app.js`. It expects `app.js` to export a plugin function and handles `listen`, logging, and graceful shutdown for you - a nice option once your app is a clean exported function (which `buildApp` already nudges you toward).
- **Let a supervisor restart crashes.** A container orchestrator (or PM2) restarts the process if it dies and runs multiple instances across CPU cores. Don't rely on `node server.js` in a terminal staying alive.

When you're ready to take the whole thing live - a domain, TLS, environment secrets, the deploy itself - [Ship Your Side Project](/guides/ship-your-side-project) walks the last mile.

## Recap

- **Testing is `app.inject()`** - Fastify's built-in dispatcher pushes a fake request through your real app in-process (every hook, schema, and route), with no port and no network. No `supertest` needed.
- **Structure the app as `buildApp()`** that returns a configured-but-not-started instance; tests build and inject into it, and only `server.js` calls `listen`. That split is what keeps tests in memory.
- **Use `payload` for request bodies** and `res.json()` to parse responses; test the unhappy paths (400, 404) too, build a fresh app per test, and `await app.close()` to clean up.
- **Turn on the built-in pino logger** (`logger: { level, redact }`) for fast structured JSON logs and per-request child loggers; read config like `PORT` from the environment.
- **Bind to `0.0.0.0`** so containers and proxies can reach you, and shut down gracefully with `await app.close()` on `SIGTERM`/`SIGINT` so in-flight requests drain.
- **Harden with official plugins** (`@fastify/helmet`, `@fastify/cors`, `@fastify/rate-limit`), run behind a reverse proxy, and consider `fastify-cli` as the runner.

## Quick check

```quiz
[
  {
    "q": "How do you send a test request to a Fastify app without opening a network port?",
    "choices": ["Install supertest and wrap the app", "Call app.inject() with a method and url - it dispatches through the real app in-process", "Start the server on a random port", "Mock every route by hand"],
    "answer": 1,
    "explain": "app.inject() is built into Fastify. It runs a fake request through the full lifecycle (hooks, schemas, handler) in the same process - no socket, no port, and no extra dependency like supertest."
  },
  {
    "q": "Why structure the app as a buildApp() function that returns the instance without calling listen?",
    "choices": ["It makes Fastify faster at runtime", "So tests can build the app and inject into it while only the entry file calls listen - sharing one definition without binding a port", "Because listen is deprecated", "To enable the pino logger"],
    "answer": 1,
    "explain": "Building without listening means a test can require the app and inject requests in memory, while server.js is the single place that opens a port. Tests and production share one app definition and never fight over the port."
  },
  {
    "q": "What does await app.close() do when called on SIGTERM during a deploy?",
    "choices": ["Kills all connections instantly", "Stops accepting new connections, lets in-flight requests finish, and runs onClose hooks before resolving", "Restarts the process", "Closes only the logger"],
    "answer": 1,
    "explain": "app.close() stops new connections, drains the requests already in flight, and runs every plugin's onClose hook (closing pools, flushing logs) before its promise resolves - so you exit cleanly without dropping responses."
  }
]
```


---

# Where to Go Next

Stop for a second and look at what you can actually do now. You can stand up a Fastify instance, declare a route as a handler plus a JSON schema and get request validation and fast response serialization for free, register encapsulated plugins so a big app stays a tidy tree instead of a tangle, hook into the request/reply lifecycle (`onRequest`, `preHandler`, and friends), build full CRUD for a resource, catch failures in one `setErrorHandler`, and test the whole thing with `app.inject()` before shipping with real logging and config. That's a production-shaped REST API, not a toy.

And here's the quieter win. You didn't only learn one framework's API - you internalized its *personality*. Fastify is two ideas repeated everywhere: **a route is a handler plus a schema, and the app is a tree of encapsulated plugins.** Validation comes from the schema. Serialization comes from the schema. Docs can come from the schema. Structure comes from encapsulation. Once you see those two patterns, the rest of Fastify stops being a pile of features and becomes a shape you can reason about - including at 2am when something's on fire.

This last phase isn't more handlers - it's the map: where Fastify sits among the other Node web frameworks, the plugin ecosystem you'll reach for, the TypeScript payoff that makes Fastify shine, and one concrete thing to go build.

## Fastify vs the field

You now know enough to choose a framework *on purpose* rather than by reputation. The plain truth is these tools aren't fighting over one spot - they're aimed at different sizes of problem and different tastes.

```mermaid
flowchart TD
  Start[Need a Node web service?] --> Raw{Want to see the raw machine?}
  Raw -- Yes --> Http[node:http by hand]
  Raw -- No, give me a framework --> Pick{What matters most?}
  Pick -- Speed + built-in validation --> Fastify[Fastify]
  Pick -- Simplicity + huge ecosystem --> Express[Express]
  Pick -- Structure for a big team --> Nest[NestJS]
```

A line on each:

- **Fastify** - fast, and built around *schemas*. You declare a JSON schema once and Fastify uses it for both validation and quick serialization, with an encapsulated plugin system instead of a flat middleware list. Reach for it when raw throughput and baked-in validation matter. (You're here.)
- **Express** - minimal and everywhere. A thin layer over `node:http` that gives you routing and a `(req, res, next)` middleware chain, then leaves body parsing, auth, and validation to libraries you assemble. The biggest ecosystem and the most likely framework to meet in a Node job. See [Express From Zero](/guides/express-from-zero).
- **NestJS** - opinionated and TypeScript-first. It brings dependency injection, modules, and decorators - structure that pays off when an app and a team get large. 💡 And here's the lovely twist: Nest can run *on Fastify* as its HTTP adapter, so the speed you learned here lives underneath Nest's structure. See [NestJS From Zero](/guides/nestjs-from-zero).
- **The bare foundation** - `node:http` itself, no framework at all. Knowing what Fastify saves you starts with knowing what you'd otherwise write by hand. See [Build a Server With node:http](/guides/build-a-server-with-node-http).

> 💡 How to pick: reach for **Fastify** when you want performance plus validation and serialization built in, **Express** when you want simplicity and the largest ecosystem and you're happy wiring the pieces yourself, and **NestJS** when you want enforced structure for a large app or team. None of these is "the best" - the senior instinct isn't memorizing a winner, it's asking "best for *this* job?" and answering straight. You can do that now.

## The plugin ecosystem you'll reach for

Fastify stays lean on purpose, and the official `@fastify/*` packages fill the gaps. They're real plugins, so they slot into the encapsulation model you already know - register them where you want their effect to apply, and that scope is respected.

- **`@fastify/cors`** - handle cross-origin requests so a browser front-end can call your API.
- **`@fastify/helmet`** - sensible security headers, set for you.
- **`@fastify/jwt`** - sign and verify JSON Web Tokens for auth. Pair it with a `preHandler` hook and you've got the same "check the request, allow or reject" pattern from Phase 4, doing a real job.
- **`@fastify/rate-limit`** - cap how often a client can hammer your routes.
- **`@fastify/postgres`** / **`@fastify/mongodb`** - a database connection decorated onto your instance, ready to use in handlers.
- **`@fastify/swagger`** - and this one is the payoff. 💡 Because your routes already carry JSON schemas, `@fastify/swagger` reads those schemas and generates an OpenAPI spec - interactive API docs - *for free*. The schema-first work you did in Phase 2 wasn't only validation insurance; it was documentation you didn't know you were writing.

That last point is worth sitting with. In Express you'd bolt on a separate tool and hand-write doc comments to describe a contract the code already knows. In Fastify, the schema *is* the single source of truth, and several plugins read from it.

## TypeScript and type providers

Fastify has strong TypeScript support out of the box, but the standout feature is the **type provider**. Normally a JSON schema gives you runtime validation, and your TypeScript types are a *separate* declaration you keep in sync by hand - two descriptions of the same shape, free to drift apart.

A type provider collapses that into one. Using `@fastify/type-provider-typebox` (or `json-schema-to-ts`), you write the schema once and Fastify *derives* the static types from it. One schema, two payoffs: runtime validation **and** compile-time types that can never disagree with what actually gets validated.

```javascript
import Fastify from 'fastify'
import { TypeBoxTypeProvider, Type } from '@fastify/type-provider-typebox'

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

app.post('/books', {
  schema: {
    body: Type.Object({
      title: Type.String(),
      year: Type.Number()
    })
  }
}, async (request) => {
  // request.body is typed { title: string; year: number } - inferred from the schema
  return { created: request.body.title }
})
```

> 💡 If you're choosing a framework for a new TypeScript service, this is a genuine reason to pick Fastify. You stop writing the same shape twice.

For data that outlives a restart, pair Fastify with a database via an ORM or a driver - **Prisma** and **Drizzle** are popular TypeScript-first picks, and `@fastify/postgres` is right there if you want to stay close to SQL. The concept underneath them all is worth understanding before you choose one: [How an ORM Works](/guides/how-an-orm-works).

## What to build

Reading more won't make this stick. Building one real thing will. So here's the assignment, and it's deliberately concrete.

Take the **books API** you grew across this guide and carry it home:

- **Add `@fastify/swagger`** and watch it generate live docs straight from the schemas you already wrote. This is the fastest "whoa" moment in the whole stack.
- **Add `@fastify/jwt`** plus a `preHandler` hook so each request proves who it is and books can belong to a user - the Phase 4 lifecycle doing real work.
- **Swap the in-memory store for a real database** through an ORM or `@fastify/postgres` so books survive a restart. If you kept data access in its own plugin the way the guide nudged, your routes barely change - you replace the bottom layer, not the top. ([How an ORM Works](/guides/how-an-orm-works) explains the concept first.)
- **If you're in TypeScript, add a type provider** so your schemas drive your types too.

And if you want to *feel* the trade-offs instead of reading about them, here's a great exercise: **port the books API to or from [Express](/guides/express-from-zero).** Nothing teaches you what a framework gives and costs like rebuilding an app you already understand. You'll feel Express's flexibility and Fastify's schemas in your hands instead of on a comparison chart.

The clear-eyed close is the same idea you've held since Phase 0. A route is a handler plus a schema, and the app is a tree of encapsulated plugins - and now that you can see that shape, you can read *any* Fastify codebase, not only the ones you wrote. Go give the books API real docs, lock it behind auth, hand it a database, deploy it, and show someone. This is fast, validated Node, and you command it now.

## Recap

1. **You can ship a real Fastify API** - schema-validated routing, encapsulated plugins, lifecycle hooks, full CRUD, one error handler, and tests via `app.inject()` - and you understand *why* each piece works.
2. **Fastify is two ideas** - a route is a handler plus a schema, and the app is a tree of encapsulated plugins. Validation, serialization, and docs all flow from the schema.
3. **Choose a framework on purpose** - Fastify for speed plus built-in validation, Express for simplicity and the largest ecosystem, NestJS for enforced structure (and Nest can even run *on* Fastify), bare `node:http` to see the raw machine.
4. **The `@fastify/*` ecosystem fills the gaps** - cors, helmet, jwt, rate-limit, a database plugin, and `@fastify/swagger`, which turns your existing schemas into OpenAPI docs for free.
5. **Type providers are the TypeScript payoff** - write a schema once and get runtime validation *and* inferred compile-time types that can't drift apart.
6. **Build and finish one thing** - add swagger, jwt, and a database to the books API (plus a type provider in TS), or port it to/from Express to feel the trade-offs.

## Quick check

Three decisions to take with you as you leave this guide:

```quiz
[
  {
    "q": "You already declared JSON schemas on your routes. Which @fastify/* plugin turns that work into interactive API documentation for free?",
    "choices": [
      "@fastify/cors",
      "@fastify/swagger, which generates an OpenAPI spec from your route schemas",
      "@fastify/rate-limit",
      "@fastify/helmet"
    ],
    "answer": 1,
    "explain": "@fastify/swagger reads the JSON schemas your routes already carry and produces an OpenAPI spec - the schema-first payoff. cors handles cross-origin requests, rate-limit caps request frequency, and helmet sets security headers."
  },
  {
    "q": "What does a Fastify type provider (like @fastify/type-provider-typebox) give you?",
    "choices": [
      "It replaces JSON schemas with TypeScript-only validation at runtime",
      "It derives compile-time TypeScript types from your JSON schema, so one schema drives both runtime validation and static types",
      "It compiles your app to native code for speed",
      "It generates the database layer automatically"
    ],
    "answer": 1,
    "explain": "A type provider infers static types from the same JSON schema Fastify already uses for validation. You write the shape once and get both runtime validation and compile-time types that can't drift apart - a standout reason to pick Fastify in TypeScript."
  },
  {
    "q": "Your team wants NestJS's structure but doesn't want to give up Fastify's speed. What's true?",
    "choices": [
      "You can't - NestJS and Fastify are mutually exclusive",
      "NestJS can run on Fastify as its HTTP adapter, so you get Nest's structure with Fastify underneath",
      "You must rewrite Fastify schemas as Express middleware first",
      "Fastify only works with bare node:http, never inside another framework"
    ],
    "answer": 1,
    "explain": "NestJS supports Fastify as its HTTP adapter, so the performance you learned here can live underneath Nest's dependency injection and module structure. Choose the layer of structure you need without abandoning the fast core."
  }
]
```
