Fastify API skill for Codex

Schema-first Fastify with deliberate encapsulation. Codex builds Fastify services around the plugin tree: shared infrastructure wrapped with fastify-plugin, feature routes kept encapsulated, and every route carrying body and response schemas that validate input and strip unlisted fields on output. It picks the correct hook phase for auth and authorization, handles Fastify 5 decorator rules, and tests through app.inject so suites run offline.

  • Backend & APIs
  • $fastify-api
  • 233 lines
  • 2 files
  • ~2,300 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- fastify-api

Installs to ~/.agents/skills/fastify-api/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $fastify-api, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds Fastify 5 services the way the framework intends - encapsulated plugins with fastify-plugin only where sharing is needed, JSON Schema or TypeBox route schemas for validation and fast serialization, typed decorators, lifecycle hooks in the right phase, setErrorHandler, pino logging, and inject-based tests. Use when creating a Fastify API, adding routes, plugins or auth hooks, migrating from Express or Fastify 4, or when a decorator is undefined in a route, response fields go missing, or throughput is lower than expected.

Use it when

  • Creating or extending a Fastify API with plugins, hooks and schemas
  • Decorators or hooks are missing in some routes due to encapsulation
  • Migrating from Express or from Fastify 4 to Fastify 5
  • Response fields go missing or throughput needs to improve

Use something else when

  • The project is an Express app and is staying on Express: use express-api
  • You want a framework with DI modules and decorators on top of Fastify: use nestjs-modules

How to check the result

Tests pass through app.inject, printPlugins shows shared plugins at the root and feature hooks scoped to their routes, and each route has a response schema for its success status.

The mistake it prevents

Registering a shared plugin without fastify-plugin, so its decorator exists only inside that plugin's own context and routes elsewhere see undefined.

~/.agents/skills/fastify-api/SKILL.md

--- name: fastify-api description: Builds Fastify 5 services the way the framework intends - encapsulated plugins with fastify-plugin only where sharing is needed, JSON Schema or… license: MIT ---

Done well means: every route declares schemas for its input and its responses, shared resources live in plugins with an explicit encapsulation decision, hooks run in the phase that matches their job, and the app is built by a function that tests call with app.inject() without opening a port.

Inspect first

  • Read AGENTS.md, then package.json: Fastify major (v5 requires Node 20+), type provider in use (TypeBox, Zod, json-schema-to-ts), @fastify/autoload, test runner.
  • Map the plugin tree: rg -n "register\(|fastify-plugin|fp\(" src.
  • Find decorators: rg -n "decorate(Request|Reply)?\(" src.
  • Check how the server listens: in containers it must bind host: "0.0.0.0"; Fastify defaults to localhost.

Layout

src/
  app.ts                # export async function buildApp(opts): FastifyInstance
  server.ts             # buildApp() + listen + close-with-grace
  plugins/              # shared infra, wrapped with fastify-plugin: db, auth, config
  routes/
    orders/
      index.ts          # routes for /orders, encapsulated
      schemas.ts

With @fastify/autoload, plugins/ loads first and routes/ folders become prefixes.

Encapsulation decisions

Each register() creates a child context. Decorators, hooks and plugins registered inside it are invisible to siblings and parents.

You are writingWrap in fastify-plugin?Why
DB pool, config, auth decorator used app-wideYesMust be visible to all routes
Auth onRequest hook for one route groupNo, register inside that groupKeeps public routes public
A feature’s routesNoEncapsulation keeps its hooks local
Third-party @fastify/* pluginAlready wrapped where neededRegister it at the level that should see it

“fastify.db is undefined” in a route almost always means the DB plugin was not wrapped with fastify-plugin or was registered in a sibling context.

// plugins/db.ts
import fp from "fastify-plugin";
import pg from "pg";

declare module "fastify" { interface FastifyInstance { db: pg.Pool } }

export default fp(async (app) => {
  const pool = new pg.Pool({ connectionString: app.config.DATABASE_URL });
  app.decorate("db", pool);
  app.addHook("onClose", async () => { await pool.end(); });
}, { name: "db", dependencies: ["config"] });

Routes with schemas (TypeBox)

import { Type, type FastifyPluginAsyncTypebox } from "@fastify/type-provider-typebox";

const Order = Type.Object({ id: Type.String(), qty: Type.Integer(), status: Type.String() });

const orders: FastifyPluginAsyncTypebox = async (app) => {
  app.addHook("onRequest", app.authenticate);       // only this context

  app.post("/", {
    schema: {
      body: Type.Object({ sku: Type.String({ minLength: 1 }), qty: Type.Integer({ minimum: 1 }) },
                        { additionalProperties: false }),
      response: { 201: Order },
    },
  }, async (req, reply) => {
    const order = await app.orders.create(req.user.id, req.body);   // req.body is typed
    return reply.code(201).send(order);
  });
};
export default orders;
  • Fastify 5 requires full JSON Schema objects for body, querystring, params and headers (type: "object" with properties); the v4 shorthand is gone.
  • Response schemas compile to a fast serializer and strip properties not in the schema. This prevents leaking password_hash, and it is also why a newly added field “disappears” until the schema is updated.
  • Fastify’s default Ajv options coerce types ("5" becomes 5), apply defaults, and use removeAdditional: true: with additionalProperties: false, unknown fields are silently stripped, not rejected. To return 400 on unknown fields instead, pass ajv: { customOptions: { removeAdditional: false } } to the Fastify factory.
  • Use app.addSchema({ $id: "Order", ... }) and { $ref: "Order#" } for shared shapes, and @fastify/swagger to publish them as OpenAPI.

Hooks: pick the phase

HookUse for
onRequestAuth, request ids, early rejection (body not parsed yet, cheapest)
preParsingTransforming the raw body stream (rare)
preValidationLogic that needs the parsed body before schema validation
preHandlerAuthorization that needs validated params/body (e.g. ownership checks)
preSerializationWrapping payloads (envelopes); not called for strings/streams
onSendHeaders on every response, payload already serialized
onResponseMetrics; response already sent
onErrorObserving errors (cannot change the response; use the error handler)
onClose (app)Releasing pools and clients on shutdown

Async hooks must not also call done. Return or throw; never both styles in one hook.

Decorators

app.decorateRequest("user", null);          // reference types must start as null
app.addHook("onRequest", async (req) => { req.user = await verify(req.headers.authorization); });

Fastify 5 throws if you decorate the request with an object or array default, because it would be shared across requests. Use null and assign per request. Declare the type via module augmentation (interface FastifyRequest { user: User | null }).

Errors

app.setErrorHandler((err, req, reply) => {
  if (err.validation) {
    return reply.code(422).send({ error: { code: "validation_failed", details: err.validation } });
  }
  const status = err.statusCode && err.statusCode < 500 ? err.statusCode : 500;
  if (status === 500) req.log.error({ err }, "unhandled");
  return reply.code(status).send({ error: { code: err.code ?? "internal", message: status === 500 ? "Internal server error" : err.message } });
});
app.setNotFoundHandler((req, reply) => reply.code(404).send({ error: { code: "not_found" } }));

Throw app.httpErrors.notFound() (from @fastify/sensible) or errors with a statusCode property. Error handlers are encapsulated too: a child context can override the parent’s.

Async handler rules

  • Return the payload, or call reply.send() and return reply. Doing both, or calling send without returning in an async handler, logs “Reply was already sent”.
  • Returning undefined from an async handler without sending is an error; return reply.code(204).send() for empty responses.

Server and shutdown

import closeWithGrace from "close-with-grace";
const app = await buildApp({ logger: { level: process.env.LOG_LEVEL ?? "info" } });
await app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" });
closeWithGrace({ delay: 10_000 }, async ({ err }) => {
  if (err) app.log.error({ err }, "closing after error");
  await app.close();                          // runs onClose hooks
});

Behind a proxy, set trustProxy to the hop count or subnet, not true.

Testing

import { test } from "node:test";
import assert from "node:assert/strict";

test("POST /orders validates qty", async (t) => {
  const app = await buildApp({ logger: false });
  t.after(() => app.close());
  const res = await app.inject({ method: "POST", url: "/orders", headers: auth, payload: { sku: "a", qty: 0 } });
  assert.equal(res.statusCode, 422);
});

inject exercises the full lifecycle (hooks, validation, serialization) with no network, so it works in a sandbox with networking disabled.

Migrating from Express

  • Replace middleware with hooks or plugins; @fastify/express or @fastify/middie exist only as a temporary bridge.
  • req.body/req.query/req.params keep their names; res becomes reply with code() instead of status().
  • Move express.json() limits to Fastify’s bodyLimit option (default 1 MiB).

Performance notes

  • Response schemas matter more for throughput than handler micro-optimizations.
  • Keep logger at info in production; debug with request serializers is costly.
  • Measure with autocannon against a production build before and after changes (installing it needs network). Compare p99 latency, not only requests per second.

Verification

Type-check, run tests, then print the route and plugin trees to confirm encapsulation:

await app.ready();
console.log(app.printRoutes());
console.log(app.printPlugins());

Check that every route in printRoutes() has a response schema for its success status (grep schema: in route files), and that an unauthenticated request to a protected group returns 401 while /healthz stays open.

Gotchas

  • Registering a plugin after listen() or ready() throws; build the whole tree first.
  • reply.raw writes bypass hooks and serialization; avoid it outside streaming.
  • app.register order matters for dependencies; declare dependencies in fastify-plugin options so a missing one fails at boot.
  • Fastify 5 removed reply.getResponseTime() (use reply.elapsedTime) and the variadic listen(port, host) signature.

Output template

## Fastify change: <summary>
- Plugins: <name, wrapped with fastify-plugin? why>
- Routes: <method path, body/response schemas>
- Hooks: <hook, context, purpose>
- Tests: <inject cases>
- printRoutes/printPlugins checked: yes/no

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Fastify API"
  short_description: "Schema-first Fastify with deliberate encapsulation"
  default_prompt: "$fastify-api Add an authenticated orders module to our Fastify app with TypeBox schemas"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem