Node.js · lesson 10 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
The Same API in Fastify
Rebuild the users API in Fastify with JSON Schema route validation, a hook, and automatic response serialization that strips fields the schema doesn't list.
By SkillByExample · Updated
Fastify validates against a JSON Schema object on the route itself, and serializes every response through its own schema too — which does something Express’s plain res.json() never does on its own.
Routes, a Hook, and JSON Schema Validation
// server.js
import Fastify from "fastify";
const app = Fastify({ logger: true });
const users = [{ id: 1, name: "Alice", email: "alice@example.com" }];
app.addHook("onRequest", async (req, reply) => {
req.log.info({ url: req.url }, "incoming request");
});
const userSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
},
};
app.get(
"/users/:id",
{
schema: {
params: { type: "object", properties: { id: { type: "integer" } } },
response: { 200: userSchema },
},
},
async (req, reply) => {
const user = users.find((u) => u.id === req.params.id);
if (!user) {
reply.code(404);
return { error: "not found" };
}
return user;
}
);
const createUserSchema = {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 2 },
email: { type: "string", format: "email" },
},
},
response: { 201: userSchema },
};
app.post("/users", { schema: createUserSchema }, async (req, reply) => {
const user = { id: users.length + 1, ...req.body };
users.push(user);
reply.code(201);
return user;
});
app.listen({ port: 3000, host: "0.0.0.0" }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
});
The "pid":1 in this lesson’s log lines is real, the same way it was in the Pino lesson: the server runs as the container’s own entrypoint process, not backgrounded inside a shell.
$ docker run -d --name fastify-demo -v "$(pwd)":/root/node-api -w /root/node-api node:26 node server.js
db9de8251135b1094976ea267629e6356df1b6a59f37a9320132fe0a98ab6938
$ docker exec fastify-demo curl -s http://localhost:3000/users/1
{"id":1,"name":"Alice","email":"alice@example.com"}
$ docker exec fastify-demo curl -s -i http://localhost:3000/users/99
HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8
content-length: 21
Date: Thu, 29 Oct 2026 13:43:00 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"error":"not found"}
$ docker exec fastify-demo curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Bob","email":"bob@example.com"}'
{"id":2,"name":"Bob","email":"bob@example.com"}
$ docker exec fastify-demo curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B"}'
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'email'"}
$ docker logs fastify-demo
{"level":30,"time":1793281375213,"pid":1,"hostname":"db9de8251135","msg":"Server listening at http://127.0.0.1:3000"}
{"level":30,"time":1793281375214,"pid":1,"hostname":"db9de8251135","msg":"Server listening at http://172.17.0.2:3000"}
{"level":30,"time":1793281376332,"pid":1,"hostname":"db9de8251135","reqId":"req-1","req":{"method":"GET","url":"/users/1","host":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":42248},"msg":"incoming request"}
{"level":30,"time":1793281376333,"pid":1,"hostname":"db9de8251135","reqId":"req-1","url":"/users/1","msg":"incoming request"}
{"level":30,"time":1793281376342,"pid":1,"hostname":"db9de8251135","reqId":"req-1","res":{"statusCode":200},"responseTime":9.26225599999998,"msg":"request completed"}
{"level":30,"time":1793281380960,"pid":1,"hostname":"db9de8251135","reqId":"req-2","req":{"method":"GET","url":"/users/99","host":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":42260},"msg":"incoming request"}
{"level":30,"time":1793281380960,"pid":1,"hostname":"db9de8251135","reqId":"req-2","url":"/users/99","msg":"incoming request"}
{"level":30,"time":1793281380961,"pid":1,"hostname":"db9de8251135","reqId":"req-2","res":{"statusCode":404},"responseTime":0.3704379999999219,"msg":"request completed"}
{"level":30,"time":1793281385229,"pid":1,"hostname":"db9de8251135","reqId":"req-3","req":{"method":"POST","url":"/users","host":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":42274},"msg":"incoming request"}
{"level":30,"time":1793281385229,"pid":1,"hostname":"db9de8251135","reqId":"req-3","url":"/users","msg":"incoming request"}
{"level":30,"time":1793281385231,"pid":1,"hostname":"db9de8251135","reqId":"req-3","res":{"statusCode":201},"responseTime":2.0918969999984256,"msg":"request completed"}
{"level":30,"time":1793281389253,"pid":1,"hostname":"db9de8251135","reqId":"req-4","req":{"method":"POST","url":"/users","host":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":58278},"msg":"incoming request"}
{"level":30,"time":1793281389254,"pid":1,"hostname":"db9de8251135","reqId":"req-4","url":"/users","msg":"incoming request"}
{"level":30,"time":1793281389254,"pid":1,"hostname":"db9de8251135","reqId":"req-4","res":{"statusCode":400},"err":{"type":"Error","message":"body must have required property 'email'","stack":"Error: body must have required property 'email'\n at defaultSchemaErrorFormatter (/root/node-api/node_modules/fastify/lib/context.js:92:10)\n at wrapValidationError (/root/node-api/node_modules/fastify/lib/validation.js:505:17)\n at validate (/root/node-api/node_modules/fastify/lib/validation.js:423:16)\n at preValidationCallback (/root/node-api/node_modules/fastify/lib/handle-request.js:115:25)\n at handler (/root/node-api/node_modules/fastify/lib/handle-request.js:99:7)\n at onDone (/root/node-api/node_modules/fastify/lib/content-type-parser.js:241:5)\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at done (/root/node-api/node_modules/fastify/lib/content-type-parser.js:211:14)\n at Parser.defaultJsonParser [as fn] (/root/node-api/node_modules/fastify/lib/content-type-parser.js:338:7)\n at IncomingMessage.onEnd (/root/node-api/node_modules/fastify/lib/content-type-parser.js:320:27)","statusCode":400,"code":"FST_ERR_VALIDATION","validation":[{"instancePath":"","schemaPath":"#/required","keyword":"required","params":{"missingProperty":"email"},"message":"must have required property 'email'"}],"validationContext":"body"},"msg":"body must have required property 'email'"}
{"level":30,"time":1793281389255,"pid":1,"hostname":"db9de8251135","reqId":"req-4","res":{"statusCode":400},"responseTime":1.3088859999988927,"msg":"request completed"}
{ logger: true } is all it takes to get Pino-shaped JSON logs — no separate pino-http install, unlike Express. "incoming request" appears twice for the one GET /users/1: once from Fastify’s own built-in request log (the line with a full req object), and once from this lesson’s onRequest hook, which happens to log that exact phrase too. The invalid POST never reached the handler at all — Fastify validated req.body against createUserSchema.body before calling it, and the message names the exact missing property, in Fastify’s own { statusCode, code, error, message } shape rather than the { error, details } object the Zod lesson built by hand.
Response Schemas Strip Fields They Don’t List
// server-serialize.js
import Fastify from "fastify";
const app = Fastify({ logger: false });
const users = [{ id: 1, name: "Alice", email: "alice@example.com", passwordHash: "s3cr3t-hash" }];
app.get(
"/users/:id",
{
schema: {
response: {
200: {
type: "object",
properties: { id: { type: "integer" }, name: { type: "string" }, email: { type: "string" } },
},
},
},
},
async (req, reply) => {
const user = users.find((u) => u.id === Number(req.params.id));
return user;
}
);
app.listen({ port: 3000, host: "0.0.0.0" });
$ node server-serialize.js &
$ curl -s http://localhost:3000/users/1
{"id":1,"name":"Alice","email":"alice@example.com"}
The handler returns the whole users[0] object, passwordHash included — but the response never shows it. Fastify serializes every response through the route’s response schema, and a property the schema doesn’t list is dropped before the bytes go out, regardless of what the handler actually returned. This is categorically different from the earlier Zod lesson, where nothing stopped a handler from accidentally returning a field like a password hash in the JSON body — here the schema itself is the safety net, on the way out, not just on the way in.
Practice
1. Add a GET /users route with a response schema that’s an array of userSchema, and confirm it still works with the existing users array.
Solution
// server.js (added route)
app.get(
"/users",
{ schema: { response: { 200: { type: "array", items: userSchema } } } },
async (req, reply) => {
return users;
}
);
The route was added to the file, so it needs docker restart before it exists — which also re-runs the module top to bottom, resetting the in-memory users array back to just Alice. Re-POSTing Bob brings it back to two before the new route has anything to show:
$ docker restart fastify-demo
fastify-demo
$ docker exec fastify-demo curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Bob","email":"bob@example.com"}'
{"id":2,"name":"Bob","email":"bob@example.com"}
$ docker exec fastify-demo curl -s http://localhost:3000/users
[{"id":1,"name":"Alice","email":"alice@example.com"},{"id":2,"name":"Bob","email":"bob@example.com"}]
{ type: "array", items: userSchema } reuses the same object schema already written for the single-user route — Fastify’s serializer walks every element of the returned array through it.
2. Send a POST body where email is present but not a valid email format, and read what format: "email" actually catches.
Solution
$ docker exec fastify-demo curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Nora","email":"not-an-email"}'
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body/email must match format \"email\""}
Fastify’s bundled validator does enforce format: "email" out of the box, rejecting the request before the handler ever runs — the error names the exact field and the format it failed, in the same { statusCode, code, error, message } shape every other validation failure in this lesson used.
3. Add a minimum: 1 constraint to the :id route param’s schema, and send /users/-1 to see Fastify reject it before the handler runs.
Solution
// server.js (added constraint)
app.get(
"/users/:id",
{
schema: {
params: { type: "object", properties: { id: { type: "integer", minimum: 1 } } },
response: { 200: userSchema },
},
},
async (req, reply) => {
const user = users.find((u) => u.id === req.params.id);
if (!user) {
reply.code(404);
return { error: "not found" };
}
return user;
}
);
$ docker restart fastify-demo
fastify-demo
$ docker exec fastify-demo curl -s http://localhost:3000/users/-1
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"params/id must be >= 1"}
The validation error names the exact failing path, params/id, and runs before the route handler’s first line — the handler’s users.find never executes for this request at all.
4. Register a second onRequest hook (in addition to the existing one) and confirm both run, in registration order, for the same request.
Solution
// server.js (added second hook)
app.addHook("onRequest", async (req, reply) => {
req.log.info("second hook ran");
});
$ docker restart fastify-demo
fastify-demo
$ docker exec fastify-demo curl -s http://localhost:3000/users/1 -o /dev/null
$ docker logs --tail 4 fastify-demo
{"level":30,"time":1793281562284,"pid":1,"hostname":"db9de8251135","reqId":"req-1","req":{"method":"GET","url":"/users/1","host":"localhost:3000","remoteAddress":"127.0.0.1","remotePort":47310},"msg":"incoming request"}
{"level":30,"time":1793281562285,"pid":1,"hostname":"db9de8251135","reqId":"req-1","url":"/users/1","msg":"incoming request"}
{"level":30,"time":1793281562285,"pid":1,"hostname":"db9de8251135","reqId":"req-1","msg":"second hook ran"}
{"level":30,"time":1793281562288,"pid":1,"hostname":"db9de8251135","reqId":"req-1","res":{"statusCode":200},"responseTime":3.7407560000010562,"msg":"request completed"}
The first line is Fastify’s own built-in request log (the one with the full req object), not a hook; then the first onRequest hook runs, then the second, then Fastify’s own "request completed" line once the handler finishes — all four carry the same reqId, and the two hook-logged lines run strictly in the order each was registered with addHook. docker restart keeps the same container (same hostname, db9de8251135, as the main example) but is still a new Node process, which is why reqId resets to req-1.