Fastify Plugins
Organize Fastify apps with encapsulated plugins, autoload, and shared decorators.
Recipe
Quick-reference recipe card - copy-paste ready.
import Fastify from "fastify";
import fp from "fastify-plugin";
import autoload from "@fastify/autoload";
import { join } from "node:path";
const app = Fastify({ logger: true });
// Shared decorator (breaks encapsulation intentionally)
await app.register(fp(async (fastify) => {
fastify.decorate("db", { query: async (sql: string) => [] });
}));
// Autoload all plugins in ./plugins and routes in ./routes
await app.register(autoload, { dir: join(import.meta.dirname, "plugins") });
await app.register(autoload, { dir: join(import.meta.dirname, "routes") });
await app.listen({ port: 3000 });When to reach for this: Any Fastify app beyond a single file. Plugins are the primary modularity unit.
Working Example
src/
app.ts
plugins/
auth.ts
database.ts
routes/
users.ts
health.ts
// plugins/database.ts
import fp from "fastify-plugin";
export default fp(async (fastify) => {
const pool = { query: async (sql: string) => [{ id: 1 }] };
fastify.decorate("db", pool);
fastify.addHook("onClose", async () => {
// close pool connections
});
});
// routes/users.ts
import { FastifyPluginAsync } from "fastify";
const users: FastifyPluginAsync = async (fastify) => {
fastify.get("/", async () => {
return fastify.db.query("SELECT * FROM users");
});
};
export default users;
// app.ts
import Fastify from "fastify";
import autoload from "@fastify/autoload";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = Fastify({ logger: true });
await app.register(autoload, { dir: join(__dirname, "plugins") });
await app.register(autoload, { dir: join(__dirname, "routes"), options: { prefix: "/api" } });What this demonstrates:
fastify-pluginwraps shared plugins for global scope@fastify/autoloaddiscovers plugins by directory conventiononClosehook for cleanup on shutdown- Route plugins mounted with
/apiprefix
Deep Dive
How It Works
register(plugin)creates a new encapsulation context- Decorators, hooks, and routes inside a plugin are invisible to sibling plugins
fastify-plugin(fp) lifts a plugin to the parent scope- Autoload reads directory and registers each file as a plugin
Encapsulation Rules
| Pattern | Scope | Use for |
|---|---|---|
Plain register | Child scope only | Feature modules |
fastify-plugin | Parent scope | DB, auth, config |
prefix option | URL prefix | Route grouping |
onClose hook | Cleanup | Connection pools |
TypeScript Declaration Merging
import "fastify";
declare module "fastify" {
interface FastifyInstance {
db: { query: (sql: string) => Promise<unknown[]> };
}
}Gotchas
- Decorator not visible in routes - registered in sibling plugin without
fp. Fix: wrap withfastify-plugin. - Circular plugin dependencies - plugin A registers B which registers A. Fix: extract shared deps to a third plugin.
- Autoload order is filesystem order - auth plugin may load after routes. Fix: use
plugins/androutes/dirs; plugins dir loads first. - Async plugin without await - routes register before DB is ready. Fix:
await app.register(dbPlugin)before routes. - Missing
onClosecleanup - connection pool leaks on SIGTERM. Fix: close pools inonClose. - Default export vs named - autoload expects
export defaultasync function. Fix: follow convention.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Manual register in app.ts | Small apps (< 5 plugins) | Growing codebase |
| NestJS modules | Need DI, decorators, guards | Want minimal framework |
| Express Router | Express codebase | Fastify project |
| Monolith single file | Spike/prototype | Production service |
FAQs
What is the difference between plugin and route?
Both use register. Routes are plugins that only define endpoints. Plugins can add decorators, hooks, and child plugins.
How does autoload determine order?
Alphabetical by filename. Prefix files with numbers (01-database.ts) to control order if needed.
Can plugins have options?
Yes. fastify.register(plugin, { prefix: "/api", dbUrl: "..." }). Access via fastify-plugin opts or closure.
How do I test a plugin in isolation?
Create a bare Fastify() instance, register the plugin, use inject(). No port needed.
Should each feature be a plugin?
Yes. One plugin per domain (users, orders, billing) with its own routes, hooks, and schemas.
How do I share schemas across plugins?
Register with fastify.addSchema() in a schemas plugin loaded first, then $ref in route schemas.
Does autoload work with TypeScript?
Use tsx or compile to JS first. Autoload loads .js files from the build output in production.
How does this compare to NestJS modules?
Fastify plugins are lighter with no DI container. NestJS modules add providers, imports, and exports. See NestJS Basics.
Related
- Fastify Basics - encapsulation intro
- JSON Schema Validation - shared schemas
- Logging with Pino - logging plugin
- Testing Fastify Apps - plugin isolation tests
- Fastify Best Practices - section checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.