
Fastify Best Practices
- 221 installs
- 2 repo stars
- Updated July 24, 2026
- ilteoood/harness
This is a copy of fastify-best-practices by mcollina - installs and ranking accrue to the original listing.
fastify-best-practices is an agent skill that implements Fastify JWT authentication and protected-route patterns.
About
fastify-best-practices (packaged in harness under an authentication-focused SKILL frontmatter) gives indie API builders concrete Fastify patterns for JWT auth, protected routes, and refresh-token flows. Instead of stitching Stack Overflow snippets, you get TypeScript-oriented register/decorate examples, schema-validated login bodies, and onRequest guards suitable for a first production slice. The skill fits Prism’s Build → backend shelf but honestly supports Ship → security when you harden access control before launch. Use it when you are standing up `/login`, issuing signed claims (id, email, role), and gating `/profile`-style routes—not when you need a full IdP product comparison. OAuth and session topics appear in metadata; the excerpt emphasizes JWT as the primary path. Pair with your own secret management and rate limiting; the skill teaches structure, not compliance certification. Agents on Claude Code or Cursor can drop these patterns into an existing Fastify app register graph quickly.
- @fastify/jwt registration with sign options and request.jwtVerify guard
- authenticate onRequest hook pattern for protected routes
- JSON schema validation on login body (email + password)
- Refresh token flow patterns beyond access-token sign
- Metadata tags: auth, jwt, session, oauth, security, authorization
Fastify Best Practices by the numbers
- 221 all-time installs (skills.sh)
- +30 installs in the week ending Jul 25, 2026 (Skillselion tracking)
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 25, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ilteoood/harness --skill fastify-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 221 |
|---|---|
| repo stars | ★ 2 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | ilteoood/harness ↗ |
What it does
Implement Fastify JWT login, route protection, and refresh-token patterns with copy-paste-ready TypeScript examples.
Who is it for?
Best when you're creating TypeScript Fastify APIs and want JWT auth scaffolding before adding business routes.
Skip if: Greenfield teams choosing auth providers (Auth0, Clerk) with no self-hosted JWT, or non-Node HTTP frameworks.
When should I use this skill?
User asks for Fastify authentication, JWT, authorization, session, or OAuth patterns on a Node API.
What you get
Your Fastify app gains documented register/decorate auth hooks, schema-validated login, and protected route examples—then you add secrets handling and refresh-token storage in your own code.
- JWT plugin registration and authenticate decorator
- Login and protected route handler examples with JSON schema
Files
When to use
Use this skill when you need to:
- Develop backend applications using Fastify
- Implement Fastify plugins and route handlers
- Get guidance on Fastify architecture and patterns
- Use TypeScript with Fastify (strip types)
- Implement testing with Fastify's inject method
- Configure validation, serialization, and error handling
Quick Start
A minimal, runnable Fastify server to get started immediately:
import Fastify from 'fastify'
const app = Fastify({ logger: true })
app.get('/health', async (request, reply) => {
return { status: 'ok' }
})
const start = async () => {
await app.listen({ port: 3000, host: '0.0.0.0' })
}
start()Recommended Reading Order for Common Scenarios
- New to Fastify? Start with
plugins.md→routes.md→schemas.md - Adding authentication:
plugins.md→hooks.md→authentication.md - Improving performance:
schemas.md→serialization.md→performance.md - Setting up testing:
routes.md→testing.md - Going to production:
logging.md→configuration.md→deployment.md
How to use
Read individual rule files for detailed explanations and code examples:
- rules/plugins.md - Plugin development and encapsulation
- rules/routes.md - Route organization and handlers
- rules/schemas.md - JSON Schema validation
- rules/error-handling.md - Error handling patterns
- rules/hooks.md - Hooks and request lifecycle
- rules/authentication.md - Authentication and authorization
- rules/testing.md - Testing with inject()
- rules/performance.md - Performance optimization
- rules/logging.md - Logging with Pino
- rules/typescript.md - TypeScript integration
- rules/decorators.md - Decorators and extensions
- rules/content-type.md - Content type parsing
- rules/serialization.md - Response serialization
- rules/cors-security.md - CORS and security headers
- rules/websockets.md - WebSocket support
- rules/database.md - Database integration patterns
- rules/configuration.md - Application configuration
- rules/deployment.md - Production deployment
- rules/http-proxy.md - HTTP proxying and reply.from()
Core Principles
- Encapsulation: Fastify's plugin system provides automatic encapsulation
- Schema-first: Define schemas for validation and serialization
- Performance: Fastify is optimized for speed; use its features correctly
- Async/await: All handlers and hooks support async functions
- Minimal dependencies: Prefer Fastify's built-in features and official plugins
Authentication and Authorization
JWT Authentication with @fastify/jwt
Use @fastify/jwt for JSON Web Token authentication:
import Fastify from 'fastify';
import fastifyJwt from '@fastify/jwt';
const app = Fastify();
app.register(fastifyJwt, {
secret: process.env.JWT_SECRET,
sign: {
expiresIn: '1h',
},
});
// Decorate request with authentication method
app.decorate('authenticate', async function (request, reply) {
try {
await request.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
});
// Login route
app.post('/login', {
schema: {
body: {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string' },
},
required: ['email', 'password'],
},
},
}, async (request, reply) => {
const { email, password } = request.body;
const user = await validateCredentials(email, password);
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const token = app.jwt.sign({
id: user.id,
email: user.email,
role: user.role,
});
return { token };
});
// Protected route
app.get('/profile', {
onRequest: [app.authenticate],
}, async (request) => {
return { user: request.user };
});Refresh Tokens
Implement refresh token rotation:
import fastifyJwt from '@fastify/jwt';
import { randomBytes } from 'node:crypto';
app.register(fastifyJwt, {
secret: process.env.JWT_SECRET,
sign: {
expiresIn: '15m', // Short-lived access tokens
},
});
// Store refresh tokens (use Redis in production)
const refreshTokens = new Map<string, { userId: string; expires: number }>();
app.post('/auth/login', async (request, reply) => {
const { email, password } = request.body;
const user = await validateCredentials(email, password);
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const accessToken = app.jwt.sign({ id: user.id, role: user.role });
const refreshToken = randomBytes(32).toString('hex');
refreshTokens.set(refreshToken, {
userId: user.id,
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
});
return { accessToken, refreshToken };
});
app.post('/auth/refresh', async (request, reply) => {
const { refreshToken } = request.body;
const stored = refreshTokens.get(refreshToken);
if (!stored || stored.expires < Date.now()) {
refreshTokens.delete(refreshToken);
return reply.code(401).send({ error: 'Invalid refresh token' });
}
// Delete old token (rotation)
refreshTokens.delete(refreshToken);
const user = await db.users.findById(stored.userId);
const accessToken = app.jwt.sign({ id: user.id, role: user.role });
const newRefreshToken = randomBytes(32).toString('hex');
refreshTokens.set(newRefreshToken, {
userId: user.id,
expires: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
return { accessToken, refreshToken: newRefreshToken };
});
app.post('/auth/logout', async (request, reply) => {
const { refreshToken } = request.body;
refreshTokens.delete(refreshToken);
return { success: true };
});Role-Based Access Control
Implement RBAC with decorators:
type Role = 'admin' | 'user' | 'moderator';
// Create authorization decorator
app.decorate('authorize', function (...allowedRoles: Role[]) {
return async (request, reply) => {
await request.jwtVerify();
const userRole = request.user.role as Role;
if (!allowedRoles.includes(userRole)) {
return reply.code(403).send({
error: 'Forbidden',
message: `Role '${userRole}' is not authorized for this resource`,
});
}
};
});
// Admin only route
app.get('/admin/users', {
onRequest: [app.authorize('admin')],
}, async (request) => {
return db.users.findAll();
});
// Admin or moderator
app.delete('/posts/:id', {
onRequest: [app.authorize('admin', 'moderator')],
}, async (request) => {
await db.posts.delete(request.params.id);
return { deleted: true };
});Permission-Based Authorization
Fine-grained permission checks:
interface Permission {
resource: string;
action: 'create' | 'read' | 'update' | 'delete';
}
const rolePermissions: Record<string, Permission[]> = {
admin: [
{ resource: '*', action: 'create' },
{ resource: '*', action: 'read' },
{ resource: '*', action: 'update' },
{ resource: '*', action: 'delete' },
],
user: [
{ resource: 'posts', action: 'create' },
{ resource: 'posts', action: 'read' },
{ resource: 'comments', action: 'create' },
{ resource: 'comments', action: 'read' },
],
};
function hasPermission(role: string, resource: string, action: string): boolean {
const permissions = rolePermissions[role] || [];
return permissions.some(
(p) =>
(p.resource === '*' || p.resource === resource) &&
p.action === action
);
}
app.decorate('checkPermission', function (resource: string, action: string) {
return async (request, reply) => {
await request.jwtVerify();
if (!hasPermission(request.user.role, resource, action)) {
return reply.code(403).send({
error: 'Forbidden',
message: `Not allowed to ${action} ${resource}`,
});
}
};
});
// Usage
app.post('/posts', {
onRequest: [app.checkPermission('posts', 'create')],
}, createPostHandler);
app.delete('/posts/:id', {
onRequest: [app.checkPermission('posts', 'delete')],
}, deletePostHandler);API Key / Bearer Token Authentication
Use @fastify/bearer-auth for API key and bearer token authentication:
import bearerAuth from '@fastify/bearer-auth';
const validKeys = new Set([process.env.API_KEY]);
app.register(bearerAuth, {
keys: validKeys,
errorResponse: (err) => ({
error: 'Unauthorized',
message: 'Invalid API key',
}),
});
// All routes are now protected
app.get('/api/data', async (request) => {
return { data: [] };
});For database-backed API keys with custom validation:
import bearerAuth from '@fastify/bearer-auth';
app.register(bearerAuth, {
auth: async (key, request) => {
const apiKey = await db.apiKeys.findByKey(key);
if (!apiKey || !apiKey.active) {
return false;
}
// Track usage (fire and forget)
db.apiKeys.recordUsage(apiKey.id, {
ip: request.ip,
timestamp: new Date(),
});
request.apiKey = apiKey;
return true;
},
errorResponse: (err) => ({
error: 'Unauthorized',
message: 'Invalid API key',
}),
});OAuth 2.0 Integration
Integrate with OAuth providers using @fastify/oauth2:
import fastifyOauth2 from '@fastify/oauth2';
app.register(fastifyOauth2, {
name: 'googleOAuth2',
scope: ['profile', 'email'],
credentials: {
client: {
id: process.env.GOOGLE_CLIENT_ID,
secret: process.env.GOOGLE_CLIENT_SECRET,
},
},
startRedirectPath: '/auth/google',
callbackUri: 'http://localhost:3000/auth/google/callback',
discovery: {
issuer: 'https://accounts.google.com',
},
});
app.get('/auth/google/callback', async (request, reply) => {
const { token } = await app.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request);
// Fetch user info from Google
const userInfo = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${token.access_token}` },
}).then((r) => r.json());
// Find or create user
let user = await db.users.findByEmail(userInfo.email);
if (!user) {
user = await db.users.create({
email: userInfo.email,
name: userInfo.name,
provider: 'google',
providerId: userInfo.id,
});
}
// Generate JWT
const jwt = app.jwt.sign({ id: user.id, role: user.role });
// Redirect to frontend with token
return reply.redirect(`/auth/success?token=${jwt}`);
});Session-Based Authentication
Use @fastify/session for session management:
import fastifyCookie from '@fastify/cookie';
import fastifySession from '@fastify/session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.register(fastifyCookie);
app.register(fastifySession, {
secret: process.env.SESSION_SECRET,
store: new RedisStore({ client: redisClient }),
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 1 day
},
});
app.post('/login', async (request, reply) => {
const { email, password } = request.body;
const user = await validateCredentials(email, password);
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
request.session.userId = user.id;
request.session.role = user.role;
return { success: true };
});
app.decorate('requireSession', async function (request, reply) {
if (!request.session.userId) {
return reply.code(401).send({ error: 'Not authenticated' });
}
});
app.get('/profile', {
onRequest: [app.requireSession],
}, async (request) => {
const user = await db.users.findById(request.session.userId);
return { user };
});
app.post('/logout', async (request, reply) => {
await request.session.destroy();
return { success: true };
});Resource-Based Authorization
Check ownership of resources:
app.decorate('checkOwnership', function (getResourceOwnerId: (request) => Promise<string>) {
return async (request, reply) => {
const ownerId = await getResourceOwnerId(request);
if (ownerId !== request.user.id && request.user.role !== 'admin') {
return reply.code(403).send({
error: 'Forbidden',
message: 'You do not own this resource',
});
}
};
});
// Check post ownership
app.put('/posts/:id', {
onRequest: [
app.authenticate,
app.checkOwnership(async (request) => {
const post = await db.posts.findById(request.params.id);
return post?.authorId;
}),
],
}, updatePostHandler);
// Alternative: inline check
app.put('/posts/:id', {
onRequest: [app.authenticate],
}, async (request, reply) => {
const post = await db.posts.findById(request.params.id);
if (!post) {
return reply.code(404).send({ error: 'Post not found' });
}
if (post.authorId !== request.user.id && request.user.role !== 'admin') {
return reply.code(403).send({ error: 'Forbidden' });
}
return db.posts.update(post.id, request.body);
});Password Hashing
Use secure password hashing with argon2:
import { hash, verify } from '@node-rs/argon2';
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
}
async function verifyPassword(hash: string, password: string): Promise<boolean> {
return verify(hash, password);
}
app.post('/register', async (request, reply) => {
const { email, password } = request.body;
const hashedPassword = await hashPassword(password);
const user = await db.users.create({
email,
password: hashedPassword,
});
reply.code(201);
return { id: user.id, email: user.email };
});
app.post('/login', async (request, reply) => {
const { email, password } = request.body;
const user = await db.users.findByEmail(email);
if (!user || !(await verifyPassword(user.password, password))) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const token = app.jwt.sign({ id: user.id, role: user.role });
return { token };
});Rate Limiting for Auth Endpoints
Protect auth endpoints from brute force. IMPORTANT: For production security, you MUST configure rate limiting with a Redis backend. In-memory rate limiting is not safe for distributed deployments and can be bypassed.
import fastifyRateLimit from '@fastify/rate-limit';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// Global rate limit with Redis backend
app.register(fastifyRateLimit, {
max: 100,
timeWindow: '1 minute',
redis, // REQUIRED for production - ensures rate limiting works across all instances
});
// Stricter limit for auth endpoints
app.register(async function authRoutes(fastify) {
await fastify.register(fastifyRateLimit, {
max: 5,
timeWindow: '1 minute',
redis, // REQUIRED for production
keyGenerator: (request) => {
// Rate limit by IP + email combination
const email = request.body?.email || '';
return `${request.ip}:${email}`;
},
});
fastify.post('/login', loginHandler);
fastify.post('/register', registerHandler);
fastify.post('/forgot-password', forgotPasswordHandler);
}, { prefix: '/auth' });Application Configuration
Use env-schema for Configuration
Always use `env-schema` for configuration validation. It provides JSON Schema validation for environment variables with sensible defaults.
import Fastify from 'fastify';
import envSchema from 'env-schema';
import { Type, type Static } from '@sinclair/typebox';
const schema = Type.Object({
PORT: Type.Number({ default: 3000 }),
HOST: Type.String({ default: '0.0.0.0' }),
DATABASE_URL: Type.String(),
JWT_SECRET: Type.String({ minLength: 32 }),
LOG_LEVEL: Type.Union([
Type.Literal('trace'),
Type.Literal('debug'),
Type.Literal('info'),
Type.Literal('warn'),
Type.Literal('error'),
Type.Literal('fatal'),
], { default: 'info' }),
});
type Config = Static<typeof schema>;
const config = envSchema<Config>({
schema,
dotenv: true, // Load from .env file
});
const app = Fastify({
logger: { level: config.LOG_LEVEL },
});
app.decorate('config', config);
declare module 'fastify' {
interface FastifyInstance {
config: Config;
}
}
await app.listen({ port: config.PORT, host: config.HOST });Configuration as Plugin
Encapsulate configuration in a plugin for reuse:
import fp from 'fastify-plugin';
import envSchema from 'env-schema';
import { Type, type Static } from '@sinclair/typebox';
const schema = Type.Object({
PORT: Type.Number({ default: 3000 }),
HOST: Type.String({ default: '0.0.0.0' }),
DATABASE_URL: Type.String(),
JWT_SECRET: Type.String({ minLength: 32 }),
LOG_LEVEL: Type.String({ default: 'info' }),
});
type Config = Static<typeof schema>;
declare module 'fastify' {
interface FastifyInstance {
config: Config;
}
}
export default fp(async function configPlugin(fastify) {
const config = envSchema<Config>({
schema,
dotenv: true,
});
fastify.decorate('config', config);
}, {
name: 'config',
});Secrets Management
Handle secrets securely:
// Never log secrets
const app = Fastify({
logger: {
level: config.LOG_LEVEL,
redact: ['req.headers.authorization', '*.password', '*.secret', '*.apiKey'],
},
});
// For production, use secret managers (AWS Secrets Manager, Vault, etc.)
// Pass secrets through environment variables - never commit themFeature Flags
Implement feature flags via environment variables:
import { Type, type Static } from '@sinclair/typebox';
const schema = Type.Object({
// ... other config
FEATURE_NEW_DASHBOARD: Type.Boolean({ default: false }),
FEATURE_BETA_API: Type.Boolean({ default: false }),
});
type Config = Static<typeof schema>;
const config = envSchema<Config>({ schema, dotenv: true });
// Use in routes
app.get('/dashboard', async (request) => {
if (app.config.FEATURE_NEW_DASHBOARD) {
return { version: 'v2', data: await getNewDashboardData() };
}
return { version: 'v1', data: await getOldDashboardData() };
});Anti-Patterns to Avoid
NEVER use configuration files
// ❌ NEVER DO THIS - configuration files are an antipattern
import config from './config/production.json';
// ❌ NEVER DO THIS - per-environment config files
const env = process.env.NODE_ENV || 'development';
const config = await import(`./config/${env}.js`);Configuration files lead to:
- Security risks (secrets in files)
- Deployment complexity
- Environment drift
- Difficult secret rotation
NEVER use per-environment configuration
// ❌ NEVER DO THIS
const configs = {
development: { logLevel: 'debug' },
production: { logLevel: 'info' },
test: { logLevel: 'silent' },
};
const config = configs[process.env.NODE_ENV];Instead, use a single configuration source (environment variables) with sensible defaults. The environment controls the values, not conditional code.
Use specific environment variables, not NODE_ENV
// ❌ AVOID checking NODE_ENV
if (process.env.NODE_ENV === 'production') {
// do something
}
// ✅ BETTER - use explicit feature flags or configuration
if (app.config.ENABLE_DETAILED_LOGGING) {
// do something
}Dynamic Configuration
For configuration that needs to change without restart, fetch from an external service:
interface DynamicConfig {
rateLimit: number;
maintenanceMode: boolean;
}
let dynamicConfig: DynamicConfig = {
rateLimit: 100,
maintenanceMode: false,
};
async function refreshConfig() {
try {
const newConfig = await fetchConfigFromService();
dynamicConfig = newConfig;
app.log.info('Configuration refreshed');
} catch (error) {
app.log.error({ err: error }, 'Failed to refresh configuration');
}
}
// Refresh periodically
setInterval(refreshConfig, 60000);
// Use in hooks
app.addHook('onRequest', async (request, reply) => {
if (dynamicConfig.maintenanceMode && !request.url.startsWith('/health')) {
reply.code(503).send({ error: 'Service under maintenance' });
}
});Content Type Parsing
Default Content Type Parsers
Fastify includes parsers for common content types:
import Fastify from 'fastify';
const app = Fastify();
// Built-in parsers:
// - application/json
// - text/plain
app.post('/json', async (request) => {
// request.body is parsed JSON object
return { received: request.body };
});
app.post('/text', async (request) => {
// request.body is string for text/plain
return { text: request.body };
});Custom Content Type Parsers
Add parsers for additional content types:
// Parse application/x-www-form-urlencoded
app.addContentTypeParser(
'application/x-www-form-urlencoded',
{ parseAs: 'string' },
(request, body, done) => {
const parsed = new URLSearchParams(body);
done(null, Object.fromEntries(parsed));
},
);
// Async parser
app.addContentTypeParser(
'application/x-www-form-urlencoded',
{ parseAs: 'string' },
async (request, body) => {
const parsed = new URLSearchParams(body);
return Object.fromEntries(parsed);
},
);XML Parsing
Parse XML content:
import { XMLParser } from 'fast-xml-parser';
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
});
app.addContentTypeParser(
'application/xml',
{ parseAs: 'string' },
async (request, body) => {
return xmlParser.parse(body);
},
);
app.addContentTypeParser(
'text/xml',
{ parseAs: 'string' },
async (request, body) => {
return xmlParser.parse(body);
},
);
app.post('/xml', async (request) => {
// request.body is parsed XML as JavaScript object
return { data: request.body };
});Multipart Form Data
Use @fastify/multipart for file uploads. Configure these critical options:
import fastifyMultipart from '@fastify/multipart';
app.register(fastifyMultipart, {
// CRITICAL: Always set explicit limits
limits: {
fieldNameSize: 100, // Max field name size in bytes
fieldSize: 1024 * 1024, // Max field value size (1MB)
fields: 10, // Max number of non-file fields
fileSize: 10 * 1024 * 1024, // Max file size (10MB)
files: 5, // Max number of files
headerPairs: 2000, // Max number of header pairs
parts: 1000, // Max number of parts (fields + files)
},
// IMPORTANT: Throw on limit exceeded (default is to truncate silently!)
throwFileSizeLimit: true,
// Attach all fields to request.body for easier access
attachFieldsToBody: true,
// Only accept specific file types (security!)
// onFile: async (part) => {
// if (!['image/jpeg', 'image/png'].includes(part.mimetype)) {
// throw new Error('Invalid file type');
// }
// },
});
// Handle file upload
app.post('/upload', async (request, reply) => {
const data = await request.file();
if (!data) {
return reply.code(400).send({ error: 'No file uploaded' });
}
// data.file is a stream
const buffer = await data.toBuffer();
return {
filename: data.filename,
mimetype: data.mimetype,
size: buffer.length,
};
});
// Handle multiple files
app.post('/upload-multiple', async (request) => {
const files = [];
for await (const part of request.files()) {
const buffer = await part.toBuffer();
files.push({
filename: part.filename,
mimetype: part.mimetype,
size: buffer.length,
});
}
return { files };
});
// Handle mixed form data
app.post('/form', async (request) => {
const parts = request.parts();
const fields: Record<string, string> = {};
const files: Array<{ name: string; size: number }> = [];
for await (const part of parts) {
if (part.type === 'file') {
const buffer = await part.toBuffer();
files.push({ name: part.filename, size: buffer.length });
} else {
fields[part.fieldname] = part.value as string;
}
}
return { fields, files };
});Stream Processing
Process body as stream for large payloads:
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
// Add parser that returns stream
app.addContentTypeParser(
'application/octet-stream',
async (request, payload) => {
return payload; // Return stream directly
},
);
app.post('/upload-stream', async (request, reply) => {
const destination = createWriteStream('./upload.bin');
await pipeline(request.body, destination);
return { success: true };
});Custom JSON Parser
Replace the default JSON parser:
// Remove default parser
app.removeContentTypeParser('application/json');
// Add custom parser with error handling
app.addContentTypeParser(
'application/json',
{ parseAs: 'string' },
async (request, body) => {
try {
return JSON.parse(body);
} catch (error) {
throw {
statusCode: 400,
code: 'INVALID_JSON',
message: 'Invalid JSON payload',
};
}
},
);Content Type with Parameters
Handle content types with parameters:
// Match content type with any charset
app.addContentTypeParser(
'application/json; charset=utf-8',
{ parseAs: 'string' },
async (request, body) => {
return JSON.parse(body);
},
);
// Use regex for flexible matching
app.addContentTypeParser(
/^application\/.*\+json$/,
{ parseAs: 'string' },
async (request, body) => {
return JSON.parse(body);
},
);Catch-All Parser
Handle unknown content types:
app.addContentTypeParser('*', async (request, payload) => {
const chunks: Buffer[] = [];
for await (const chunk of payload) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Try to determine content type
const contentType = request.headers['content-type'];
if (contentType?.includes('json')) {
return JSON.parse(buffer.toString('utf-8'));
}
if (contentType?.includes('text')) {
return buffer.toString('utf-8');
}
return buffer;
});Body Limit Configuration
Configure body size limits:
// Global limit
const app = Fastify({
bodyLimit: 1048576, // 1MB
});
// Per-route limit
app.post('/large-upload', {
bodyLimit: 52428800, // 50MB for this route
}, async (request) => {
return { size: JSON.stringify(request.body).length };
});
// Per content type limit
app.addContentTypeParser('application/json', {
parseAs: 'string',
bodyLimit: 2097152, // 2MB for JSON
}, async (request, body) => {
return JSON.parse(body);
});Protocol Buffers
Parse protobuf content:
import protobuf from 'protobufjs';
const root = await protobuf.load('./schema.proto');
const MessageType = root.lookupType('package.MessageType');
app.addContentTypeParser(
'application/x-protobuf',
{ parseAs: 'buffer' },
async (request, body) => {
const message = MessageType.decode(body);
return MessageType.toObject(message);
},
);Form Data with @fastify/formbody
Simple form parsing:
import formbody from '@fastify/formbody';
app.register(formbody);
app.post('/form', async (request) => {
// request.body is parsed form data
const { name, email } = request.body as { name: string; email: string };
return { name, email };
});Content Negotiation
Handle different request formats:
app.post('/data', async (request, reply) => {
const contentType = request.headers['content-type'];
// Body is already parsed by the appropriate parser
const data = request.body;
// Respond based on Accept header
const accept = request.headers.accept;
if (accept?.includes('application/xml')) {
reply.type('application/xml');
return `<data>${JSON.stringify(data)}</data>`;
}
reply.type('application/json');
return data;
});Validation After Parsing
Validate parsed content:
app.post('/users', {
schema: {
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
},
},
}, async (request) => {
// Body is parsed AND validated
return request.body;
});CORS and Security
CORS with @fastify/cors
Enable Cross-Origin Resource Sharing:
import Fastify from 'fastify';
import cors from '@fastify/cors';
const app = Fastify();
// Simple CORS - allow all origins
app.register(cors);
// Configured CORS
app.register(cors, {
origin: ['https://example.com', 'https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count'],
credentials: true,
maxAge: 86400, // 24 hours
});Dynamic CORS Origin
Validate origins dynamically:
app.register(cors, {
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin) {
return callback(null, true);
}
// Check against allowed origins
const allowedOrigins = [
'https://example.com',
'https://app.example.com',
/\.example\.com$/,
];
const isAllowed = allowedOrigins.some((allowed) => {
if (allowed instanceof RegExp) {
return allowed.test(origin);
}
return allowed === origin;
});
if (isAllowed) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'), false);
}
},
credentials: true,
});Per-Route CORS
Configure CORS for specific routes:
app.register(cors, {
origin: true, // Reflect request origin
credentials: true,
});
// Or disable CORS for specific routes
app.route({
method: 'GET',
url: '/internal',
config: {
cors: false,
},
handler: async () => {
return { internal: true };
},
});Security Headers with @fastify/helmet
Add security headers:
import helmet from '@fastify/helmet';
app.register(helmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
},
},
crossOriginEmbedderPolicy: false, // Disable if embedding external resources
});Configure Individual Headers
Fine-tune security headers:
app.register(helmet, {
// Strict Transport Security
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
// Content Security Policy
contentSecurityPolicy: {
useDefaults: true,
directives: {
'script-src': ["'self'", 'https://trusted-cdn.com'],
},
},
// X-Frame-Options
frameguard: {
action: 'deny', // or 'sameorigin'
},
// X-Content-Type-Options
noSniff: true,
// X-XSS-Protection (legacy)
xssFilter: true,
// Referrer-Policy
referrerPolicy: {
policy: 'strict-origin-when-cross-origin',
},
// X-Permitted-Cross-Domain-Policies
permittedCrossDomainPolicies: false,
// X-DNS-Prefetch-Control
dnsPrefetchControl: {
allow: false,
},
});Rate Limiting
Protect against abuse:
import rateLimit from '@fastify/rate-limit';
app.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
errorResponseBuilder: (request, context) => ({
statusCode: 429,
error: 'Too Many Requests',
message: `Rate limit exceeded. Retry in ${context.after}`,
retryAfter: context.after,
}),
});
// Per-route rate limit
app.get('/expensive', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
},
},
}, handler);
// Skip rate limit for certain routes
app.get('/health', {
config: {
rateLimit: false,
},
}, () => ({ status: 'ok' }));Redis-Based Rate Limiting
Use Redis for distributed rate limiting:
import rateLimit from '@fastify/rate-limit';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
app.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
redis,
nameSpace: 'rate-limit:',
keyGenerator: (request) => {
// Rate limit by user ID if authenticated, otherwise by IP
return request.user?.id || request.ip;
},
});CSRF Protection
Protect against Cross-Site Request Forgery:
import fastifyCsrf from '@fastify/csrf-protection';
import fastifyCookie from '@fastify/cookie';
app.register(fastifyCookie);
app.register(fastifyCsrf, {
cookieOpts: {
signed: true,
httpOnly: true,
sameSite: 'strict',
},
});
// Generate token
app.get('/csrf-token', async (request, reply) => {
const token = reply.generateCsrf();
return { token };
});
// Protected route
app.post('/transfer', {
preHandler: app.csrfProtection,
}, async (request) => {
// CSRF token validated
return { success: true };
});Custom Security Headers
Add custom headers:
app.addHook('onSend', async (request, reply) => {
// Custom security headers
reply.header('X-Request-ID', request.id);
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('X-Frame-Options', 'DENY');
reply.header('Permissions-Policy', 'geolocation=(), camera=()');
});
// Per-route headers
app.get('/download', async (request, reply) => {
reply.header('Content-Disposition', 'attachment; filename="file.pdf"');
reply.header('X-Download-Options', 'noopen');
return reply.send(fileStream);
});Secure Cookies
Configure secure cookies:
import cookie from '@fastify/cookie';
app.register(cookie, {
secret: process.env.COOKIE_SECRET,
parseOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: 3600, // 1 hour
},
});
// Set secure cookie
app.post('/login', async (request, reply) => {
const token = await createSession(request.body);
reply.setCookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 86400,
signed: true,
});
return { success: true };
});
// Read signed cookie
app.get('/profile', async (request) => {
const session = request.cookies.session;
const unsigned = request.unsignCookie(session);
if (!unsigned.valid) {
throw { statusCode: 401, message: 'Invalid session' };
}
return { sessionId: unsigned.value };
});Request Validation Security
Validate and sanitize input:
// Schema-based validation protects against injection
app.post('/users', {
schema: {
body: {
type: 'object',
properties: {
email: {
type: 'string',
format: 'email',
maxLength: 254,
},
name: {
type: 'string',
minLength: 1,
maxLength: 100,
pattern: '^[a-zA-Z\\s]+$', // Only letters and spaces
},
},
required: ['email', 'name'],
additionalProperties: false,
},
},
}, handler);IP Filtering
Restrict access by IP:
const allowedIps = new Set([
'192.168.1.0/24',
'10.0.0.0/8',
]);
app.addHook('onRequest', async (request, reply) => {
if (request.url.startsWith('/admin')) {
const clientIp = request.ip;
if (!isIpAllowed(clientIp, allowedIps)) {
reply.code(403).send({ error: 'Forbidden' });
}
}
});
function isIpAllowed(ip: string, allowed: Set<string>): boolean {
// Implement IP/CIDR matching
for (const range of allowed) {
if (ipInRange(ip, range)) return true;
}
return false;
}Trust Proxy
Configure for reverse proxy environments:
const app = Fastify({
trustProxy: true, // Trust X-Forwarded-* headers
});
// Or specific proxy configuration
const app = Fastify({
trustProxy: ['127.0.0.1', '10.0.0.0/8'],
});
// Now request.ip returns the real client IP
app.get('/ip', async (request) => {
return {
ip: request.ip,
ips: request.ips, // Array of all IPs in chain
};
});HTTPS Redirect
Force HTTPS in production:
app.addHook('onRequest', async (request, reply) => {
if (
process.env.NODE_ENV === 'production' &&
request.headers['x-forwarded-proto'] !== 'https'
) {
const httpsUrl = `https://${request.hostname}${request.url}`;
reply.redirect(301, httpsUrl);
}
});Security Best Practices Summary
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
const app = Fastify({
trustProxy: true,
bodyLimit: 1048576, // 1MB max body
});
// Security plugins
app.register(helmet);
app.register(cors, {
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true,
});
app.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
});
// Validate all input with schemas
// Never expose internal errors in production
// Use parameterized queries for database
// Keep dependencies updatedDatabase Integration
Use Official Fastify Database Adapters
Always use the official Fastify database plugins from the @fastify organization. They provide proper connection pooling, encapsulation, and integration with Fastify's lifecycle.
PostgreSQL with @fastify/postgres
import Fastify from 'fastify';
import fastifyPostgres from '@fastify/postgres';
const app = Fastify({ logger: true });
app.register(fastifyPostgres, {
connectionString: process.env.DATABASE_URL,
});
// Use in routes
app.get('/users', async (request) => {
const client = await app.pg.connect();
try {
const { rows } = await client.query('SELECT * FROM users');
return rows;
} finally {
client.release();
}
});
// Or use the pool directly for simple queries
app.get('/users/:id', async (request) => {
const { id } = request.params;
const { rows } = await app.pg.query(
'SELECT * FROM users WHERE id = $1',
[id],
);
return rows[0];
});
// Transactions
app.post('/transfer', async (request) => {
const { fromId, toId, amount } = request.body;
const client = await app.pg.connect();
try {
await client.query('BEGIN');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, fromId],
);
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toId],
);
await client.query('COMMIT');
return { success: true };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
});MySQL with @fastify/mysql
import Fastify from 'fastify';
import fastifyMysql from '@fastify/mysql';
const app = Fastify({ logger: true });
app.register(fastifyMysql, {
promise: true,
connectionString: process.env.MYSQL_URL,
});
app.get('/users', async (request) => {
const connection = await app.mysql.getConnection();
try {
const [rows] = await connection.query('SELECT * FROM users');
return rows;
} finally {
connection.release();
}
});MongoDB with @fastify/mongodb
import Fastify from 'fastify';
import fastifyMongo from '@fastify/mongodb';
const app = Fastify({ logger: true });
app.register(fastifyMongo, {
url: process.env.MONGODB_URL,
});
app.get('/users', async (request) => {
const users = await app.mongo.db
.collection('users')
.find({})
.toArray();
return users;
});
app.get('/users/:id', async (request) => {
const { id } = request.params;
const user = await app.mongo.db
.collection('users')
.findOne({ _id: new app.mongo.ObjectId(id) });
return user;
});
app.post('/users', async (request) => {
const result = await app.mongo.db
.collection('users')
.insertOne(request.body);
return { id: result.insertedId };
});Redis with @fastify/redis
import Fastify from 'fastify';
import fastifyRedis from '@fastify/redis';
const app = Fastify({ logger: true });
app.register(fastifyRedis, {
url: process.env.REDIS_URL,
});
// Caching example
app.get('/data/:key', async (request) => {
const { key } = request.params;
// Try cache first
const cached = await app.redis.get(`cache:${key}`);
if (cached) {
return JSON.parse(cached);
}
// Fetch from database
const data = await fetchFromDatabase(key);
// Cache for 5 minutes
await app.redis.setex(`cache:${key}`, 300, JSON.stringify(data));
return data;
});Database as Plugin
Encapsulate database access in a plugin:
// plugins/database.ts
import fp from 'fastify-plugin';
import fastifyPostgres from '@fastify/postgres';
export default fp(async function databasePlugin(fastify) {
await fastify.register(fastifyPostgres, {
connectionString: fastify.config.DATABASE_URL,
});
// Add health check
fastify.decorate('checkDatabaseHealth', async () => {
try {
await fastify.pg.query('SELECT 1');
return true;
} catch {
return false;
}
});
}, {
name: 'database',
dependencies: ['config'],
});Repository Pattern
Abstract database access with repositories:
// repositories/user.repository.ts
import type { FastifyInstance } from 'fastify';
export interface User {
id: string;
email: string;
name: string;
}
export function createUserRepository(app: FastifyInstance) {
return {
async findById(id: string): Promise<User | null> {
const { rows } = await app.pg.query(
'SELECT * FROM users WHERE id = $1',
[id],
);
return rows[0] || null;
},
async findByEmail(email: string): Promise<User | null> {
const { rows } = await app.pg.query(
'SELECT * FROM users WHERE email = $1',
[email],
);
return rows[0] || null;
},
async create(data: Omit<User, 'id'>): Promise<User> {
const { rows } = await app.pg.query(
'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
[data.email, data.name],
);
return rows[0];
},
async update(id: string, data: Partial<User>): Promise<User | null> {
const fields = Object.keys(data);
const values = Object.values(data);
const setClause = fields
.map((f, i) => `${f} = $${i + 2}`)
.join(', ');
const { rows } = await app.pg.query(
`UPDATE users SET ${setClause} WHERE id = $1 RETURNING *`,
[id, ...values],
);
return rows[0] || null;
},
async delete(id: string): Promise<boolean> {
const { rowCount } = await app.pg.query(
'DELETE FROM users WHERE id = $1',
[id],
);
return rowCount > 0;
},
};
}
// Usage in plugin
import fp from 'fastify-plugin';
import { createUserRepository } from './repositories/user.repository.js';
export default fp(async function repositoriesPlugin(fastify) {
fastify.decorate('repositories', {
users: createUserRepository(fastify),
});
}, {
name: 'repositories',
dependencies: ['database'],
});Testing with Database
Use transactions for test isolation:
import { describe, it, beforeEach, afterEach } from 'node:test';
import { build } from './app.js';
describe('User API', () => {
let app;
let client;
beforeEach(async () => {
app = await build();
client = await app.pg.connect();
await client.query('BEGIN');
});
afterEach(async () => {
await client.query('ROLLBACK');
client.release();
await app.close();
});
it('should create a user', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: { email: 'test@example.com', name: 'Test' },
});
t.assert.equal(response.statusCode, 201);
});
});Connection Pool Configuration
Configure connection pools appropriately:
app.register(fastifyPostgres, {
connectionString: process.env.DATABASE_URL,
// Pool configuration
max: 20, // Maximum pool size
idleTimeoutMillis: 30000, // Close idle clients after 30s
connectionTimeoutMillis: 5000, // Timeout for new connections
});Decorators and Extensions
Understanding Decorators
Decorators add custom properties and methods to Fastify instances, requests, and replies:
import Fastify from 'fastify';
const app = Fastify();
// Decorate the Fastify instance
app.decorate('utility', {
formatDate: (date: Date) => date.toISOString(),
generateId: () => crypto.randomUUID(),
});
// Use in routes
app.get('/example', async function (request, reply) {
const id = this.utility.generateId();
return { id, timestamp: this.utility.formatDate(new Date()) };
});Decorator Types
Three types of decorators for different contexts:
// Instance decorator - available on fastify instance
app.decorate('config', { apiVersion: '1.0.0' });
app.decorate('db', databaseConnection);
app.decorate('cache', cacheClient);
// Request decorator - available on each request
app.decorateRequest('user', null); // Object property
app.decorateRequest('startTime', 0); // Primitive
app.decorateRequest('getData', function() { // Method
return this.body;
});
// Reply decorator - available on each reply
app.decorateReply('sendError', function(code: number, message: string) {
return this.code(code).send({ error: message });
});
app.decorateReply('success', function(data: unknown) {
return this.send({ success: true, data });
});TypeScript Declaration Merging
Extend Fastify types for type safety:
// Declare custom properties
declare module 'fastify' {
interface FastifyInstance {
config: {
apiVersion: string;
environment: string;
};
db: DatabaseClient;
cache: CacheClient;
}
interface FastifyRequest {
user: {
id: string;
email: string;
roles: string[];
} | null;
startTime: number;
requestId: string;
}
interface FastifyReply {
sendError: (code: number, message: string) => void;
success: (data: unknown) => void;
}
}
// Register decorators
app.decorate('config', {
apiVersion: '1.0.0',
environment: process.env.NODE_ENV,
});
app.decorateRequest('user', null);
app.decorateRequest('startTime', 0);
app.decorateReply('sendError', function (code: number, message: string) {
this.code(code).send({ error: message });
});Decorator Initialization
Initialize request/reply decorators in hooks:
// Decorators with primitive defaults are copied
app.decorateRequest('startTime', 0);
// Initialize in hook
app.addHook('onRequest', async (request) => {
request.startTime = Date.now();
});
// Object decorators need getter pattern for proper initialization
app.decorateRequest('context', null);
app.addHook('onRequest', async (request) => {
request.context = {
traceId: request.headers['x-trace-id'] || crypto.randomUUID(),
clientIp: request.ip,
userAgent: request.headers['user-agent'],
};
});Dependency Injection with Decorators
Use decorators for dependency injection:
import fp from 'fastify-plugin';
// Database plugin
export default fp(async function databasePlugin(fastify, options) {
const db = await createDatabaseConnection(options.connectionString);
fastify.decorate('db', db);
fastify.addHook('onClose', async () => {
await db.close();
});
});
// User service plugin
export default fp(async function userServicePlugin(fastify) {
// Depends on db decorator
if (!fastify.hasDecorator('db')) {
throw new Error('Database plugin must be registered first');
}
const userService = {
findById: (id: string) => fastify.db.query('SELECT * FROM users WHERE id = $1', [id]),
create: (data: CreateUserInput) => fastify.db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[data.name, data.email]
),
};
fastify.decorate('userService', userService);
}, {
dependencies: ['database-plugin'],
});
// Use in routes
app.get('/users/:id', async function (request) {
const user = await this.userService.findById(request.params.id);
return user;
});Request Context Pattern
Build rich request context:
interface RequestContext {
traceId: string;
user: User | null;
permissions: Set<string>;
startTime: number;
metadata: Map<string, unknown>;
}
declare module 'fastify' {
interface FastifyRequest {
ctx: RequestContext;
}
}
app.decorateRequest('ctx', null);
app.addHook('onRequest', async (request) => {
request.ctx = {
traceId: request.headers['x-trace-id']?.toString() || crypto.randomUUID(),
user: null,
permissions: new Set(),
startTime: Date.now(),
metadata: new Map(),
};
});
// Auth hook populates user
app.addHook('preHandler', async (request) => {
const token = request.headers.authorization;
if (token) {
const user = await verifyToken(token);
request.ctx.user = user;
request.ctx.permissions = new Set(user.permissions);
}
});
// Use in handlers
app.get('/profile', async (request, reply) => {
if (!request.ctx.user) {
return reply.code(401).send({ error: 'Unauthorized' });
}
if (!request.ctx.permissions.has('read:profile')) {
return reply.code(403).send({ error: 'Forbidden' });
}
return request.ctx.user;
});Reply Helpers
Create consistent response methods:
declare module 'fastify' {
interface FastifyReply {
ok: (data?: unknown) => void;
created: (data: unknown) => void;
noContent: () => void;
badRequest: (message: string, details?: unknown) => void;
unauthorized: (message?: string) => void;
forbidden: (message?: string) => void;
notFound: (resource?: string) => void;
conflict: (message: string) => void;
serverError: (message?: string) => void;
}
}
app.decorateReply('ok', function (data?: unknown) {
this.code(200).send(data ?? { success: true });
});
app.decorateReply('created', function (data: unknown) {
this.code(201).send(data);
});
app.decorateReply('noContent', function () {
this.code(204).send();
});
app.decorateReply('badRequest', function (message: string, details?: unknown) {
this.code(400).send({
statusCode: 400,
error: 'Bad Request',
message,
details,
});
});
app.decorateReply('unauthorized', function (message = 'Authentication required') {
this.code(401).send({
statusCode: 401,
error: 'Unauthorized',
message,
});
});
app.decorateReply('notFound', function (resource = 'Resource') {
this.code(404).send({
statusCode: 404,
error: 'Not Found',
message: `${resource} not found`,
});
});
// Usage
app.get('/users/:id', async (request, reply) => {
const user = await db.users.findById(request.params.id);
if (!user) {
return reply.notFound('User');
}
return reply.ok(user);
});
app.post('/users', async (request, reply) => {
const user = await db.users.create(request.body);
return reply.created(user);
});Checking Decorators
Check if decorators exist before using:
// Check at registration time
app.register(async function (fastify) {
if (!fastify.hasDecorator('db')) {
throw new Error('Database decorator required');
}
if (!fastify.hasRequestDecorator('user')) {
throw new Error('User request decorator required');
}
if (!fastify.hasReplyDecorator('sendError')) {
throw new Error('sendError reply decorator required');
}
// Safe to use decorators
});Decorator Encapsulation
Decorators respect encapsulation by default:
app.register(async function pluginA(fastify) {
fastify.decorate('pluginAUtil', () => 'A');
fastify.get('/a', async function () {
return this.pluginAUtil(); // Works
});
});
app.register(async function pluginB(fastify) {
// this.pluginAUtil is NOT available here (encapsulated)
fastify.get('/b', async function () {
// this.pluginAUtil() would be undefined
});
});Use fastify-plugin to share decorators:
import fp from 'fastify-plugin';
export default fp(async function sharedDecorator(fastify) {
fastify.decorate('sharedUtil', () => 'shared');
});
// Now available to parent and sibling pluginsFunctional Decorators
Create decorators that return functions:
declare module 'fastify' {
interface FastifyInstance {
createValidator: <T>(schema: object) => (data: unknown) => T;
createRateLimiter: (options: RateLimitOptions) => RateLimiter;
}
}
app.decorate('createValidator', function <T>(schema: object) {
const validate = ajv.compile(schema);
return (data: unknown): T => {
if (!validate(data)) {
throw new ValidationError(validate.errors);
}
return data as T;
};
});
// Usage
const validateUser = app.createValidator<User>(userSchema);
app.post('/users', async (request) => {
const user = validateUser(request.body);
return db.users.create(user);
});Async Decorator Initialization
Handle async initialization properly:
import fp from 'fastify-plugin';
export default fp(async function asyncPlugin(fastify) {
// Async initialization
const connection = await createAsyncConnection();
const cache = await initializeCache();
fastify.decorate('asyncService', {
connection,
cache,
query: async (sql: string) => connection.query(sql),
});
fastify.addHook('onClose', async () => {
await connection.close();
await cache.disconnect();
});
});
// Plugin is fully initialized before routes execute
app.get('/data', async function () {
return this.asyncService.query('SELECT * FROM data');
});Production Deployment
Graceful Shutdown with close-with-grace
Use close-with-grace for proper shutdown handling:
import Fastify from 'fastify';
import closeWithGrace from 'close-with-grace';
const app = Fastify({ logger: true });
// Register plugins and routes
await app.register(import('./plugins/index.js'));
await app.register(import('./routes/index.js'));
// Graceful shutdown handler
closeWithGrace({ delay: 10000 }, async ({ signal, err }) => {
if (err) {
app.log.error({ err }, 'Server closing due to error');
} else {
app.log.info({ signal }, 'Server closing due to signal');
}
await app.close();
});
// Start server
await app.listen({
port: parseInt(process.env.PORT || '3000', 10),
host: '0.0.0.0',
});
app.log.info(`Server listening on ${app.server.address()}`);Health Check Endpoints
Implement comprehensive health checks:
app.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
app.get('/health/live', async () => {
return { status: 'ok' };
});
app.get('/health/ready', async (request, reply) => {
const checks = {
database: false,
cache: false,
};
try {
await app.db`SELECT 1`;
checks.database = true;
} catch {
// Database not ready
}
try {
await app.cache.ping();
checks.cache = true;
} catch {
// Cache not ready
}
const allHealthy = Object.values(checks).every(Boolean);
if (!allHealthy) {
reply.code(503);
}
return {
status: allHealthy ? 'ok' : 'degraded',
checks,
timestamp: new Date().toISOString(),
};
});
// Detailed health for monitoring
app.get('/health/details', {
preHandler: [app.authenticate, app.requireAdmin],
}, async () => {
const memory = process.memoryUsage();
return {
status: 'ok',
uptime: process.uptime(),
memory: {
heapUsed: Math.round(memory.heapUsed / 1024 / 1024),
heapTotal: Math.round(memory.heapTotal / 1024 / 1024),
rss: Math.round(memory.rss / 1024 / 1024),
},
version: process.env.APP_VERSION,
nodeVersion: process.version,
};
});Docker Configuration
Create an optimized Dockerfile:
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Production stage
FROM node:22-alpine
WORKDIR /app
# Run as non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy from builder
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/src ./src
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
USER nodejs
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "src/app.ts"]# docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://user:pass@db:5432/app
- JWT_SECRET=${JWT_SECRET}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d app"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:Kubernetes Deployment
Deploy to Kubernetes:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: fastify-api
spec:
replicas: 3
selector:
matchLabels:
app: fastify-api
template:
metadata:
labels:
app: fastify-api
spec:
containers:
- name: api
image: my-registry/fastify-api:latest
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: api-secrets
key: database-url
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
---
apiVersion: v1
kind: Service
metadata:
name: fastify-api
spec:
selector:
app: fastify-api
ports:
- port: 80
targetPort: 3000
type: ClusterIPProduction Logger Configuration
Configure logging for production:
import Fastify from 'fastify';
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
// JSON output for log aggregation
formatters: {
level: (label) => ({ level: label }),
bindings: (bindings) => ({
pid: bindings.pid,
hostname: bindings.hostname,
service: 'fastify-api',
version: process.env.APP_VERSION,
}),
},
timestamp: () => `,"time":"${new Date().toISOString()}"`,
// Redact sensitive data
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'*.password',
'*.token',
'*.secret',
],
censor: '[REDACTED]',
},
},
});Request Timeouts
Configure appropriate timeouts:
const app = Fastify({
connectionTimeout: 30000, // 30s connection timeout
keepAliveTimeout: 72000, // 72s keep-alive (longer than ALB 60s)
requestTimeout: 30000, // 30s request timeout
bodyLimit: 1048576, // 1MB body limit
});
// Per-route timeout
app.get('/long-operation', {
config: {
timeout: 60000, // 60s for this route
},
}, longOperationHandler);Trust Proxy Settings
Configure for load balancers:
const app = Fastify({
// Trust first proxy (load balancer)
trustProxy: true,
// Or trust specific proxies
trustProxy: ['127.0.0.1', '10.0.0.0/8'],
// Or number of proxies to trust
trustProxy: 1,
});
// Now request.ip returns real client IPStatic File Serving
Serve static files efficiently. Always use `import.meta.dirname` as the base path, never process.cwd():
import fastifyStatic from '@fastify/static';
import { join } from 'node:path';
app.register(fastifyStatic, {
root: join(import.meta.dirname, '..', 'public'),
prefix: '/static/',
maxAge: '1d',
immutable: true,
etag: true,
lastModified: true,
});Compression
Enable response compression:
import fastifyCompress from '@fastify/compress';
app.register(fastifyCompress, {
global: true,
threshold: 1024, // Only compress > 1KB
encodings: ['gzip', 'deflate'],
});Metrics and Monitoring
Expose Prometheus metrics:
import { register, collectDefaultMetrics, Counter, Histogram } from 'prom-client';
collectDefaultMetrics();
const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
});
const httpRequestTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status'],
});
app.addHook('onResponse', (request, reply, done) => {
const route = request.routeOptions.url || request.url;
const labels = {
method: request.method,
route,
status: reply.statusCode,
};
httpRequestDuration.observe(labels, reply.elapsedTime / 1000);
httpRequestTotal.inc(labels);
done();
});
app.get('/metrics', async (request, reply) => {
reply.header('Content-Type', register.contentType);
return register.metrics();
});Zero-Downtime Deployments
Support rolling updates:
import closeWithGrace from 'close-with-grace';
// Stop accepting new connections gracefully
closeWithGrace({ delay: 30000 }, async ({ signal }) => {
app.log.info({ signal }, 'Received shutdown signal');
// Stop accepting new connections
// Existing connections continue to be served
// Wait for in-flight requests (handled by close-with-grace delay)
await app.close();
app.log.info('Server closed');
});Error Handling in Fastify
Default Error Handler
Fastify has a built-in error handler. Thrown errors automatically become HTTP responses:
import Fastify from 'fastify';
const app = Fastify({ logger: true });
app.get('/users/:id', async (request) => {
const user = await findUser(request.params.id);
if (!user) {
// Throwing an error with statusCode sets the response status
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
return user;
});Custom Error Classes
Use @fastify/error for creating typed errors:
import createError from '@fastify/error';
const NotFoundError = createError('NOT_FOUND', '%s not found', 404);
const UnauthorizedError = createError('UNAUTHORIZED', 'Authentication required', 401);
const ForbiddenError = createError('FORBIDDEN', 'Access denied: %s', 403);
const ValidationError = createError('VALIDATION_ERROR', '%s', 400);
const ConflictError = createError('CONFLICT', '%s already exists', 409);
// Usage
app.get('/users/:id', async (request) => {
const user = await findUser(request.params.id);
if (!user) {
throw new NotFoundError('User');
}
return user;
});
app.post('/users', async (request) => {
const exists = await userExists(request.body.email);
if (exists) {
throw new ConflictError('Email');
}
return createUser(request.body);
});Custom Error Handler
Implement a centralized error handler:
import Fastify from 'fastify';
import type { FastifyError, FastifyRequest, FastifyReply } from 'fastify';
const app = Fastify({ logger: true });
app.setErrorHandler((error: FastifyError, request: FastifyRequest, reply: FastifyReply) => {
// Log the error
request.log.error({ err: error }, 'Request error');
// Handle validation errors
if (error.validation) {
return reply.code(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'Validation failed',
details: error.validation,
});
}
// Handle known errors with status codes
const statusCode = error.statusCode ?? 500;
const code = error.code ?? 'INTERNAL_ERROR';
// Don't expose internal error details in production
const message = statusCode >= 500 && process.env.NODE_ENV === 'production'
? 'Internal Server Error'
: error.message;
return reply.code(statusCode).send({
statusCode,
error: code,
message,
});
});Error Response Schema
Define consistent error response schemas:
app.addSchema({
$id: 'httpError',
type: 'object',
properties: {
statusCode: { type: 'integer' },
error: { type: 'string' },
message: { type: 'string' },
details: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
message: { type: 'string' },
},
},
},
},
required: ['statusCode', 'error', 'message'],
});
// Use in route schemas
app.get('/users/:id', {
schema: {
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
response: {
200: { $ref: 'user#' },
404: { $ref: 'httpError#' },
500: { $ref: 'httpError#' },
},
},
}, handler);Reply Helpers with @fastify/sensible
Use @fastify/sensible for standard HTTP errors:
import fastifySensible from '@fastify/sensible';
app.register(fastifySensible);
app.get('/users/:id', async (request, reply) => {
const user = await findUser(request.params.id);
if (!user) {
return reply.notFound('User not found');
}
if (!hasAccess(request.user, user)) {
return reply.forbidden('You cannot access this user');
}
return user;
});
// Available methods:
// reply.badRequest(message?)
// reply.unauthorized(message?)
// reply.forbidden(message?)
// reply.notFound(message?)
// reply.methodNotAllowed(message?)
// reply.conflict(message?)
// reply.gone(message?)
// reply.unprocessableEntity(message?)
// reply.tooManyRequests(message?)
// reply.internalServerError(message?)
// reply.notImplemented(message?)
// reply.badGateway(message?)
// reply.serviceUnavailable(message?)
// reply.gatewayTimeout(message?)Async Error Handling
Errors in async handlers are automatically caught:
// Errors are automatically caught and passed to error handler
app.get('/users', async (request) => {
const users = await db.users.findAll(); // If this throws, error handler catches it
return users;
});
// Explicit error handling for custom logic
app.get('/users/:id', async (request, reply) => {
try {
const user = await db.users.findById(request.params.id);
if (!user) {
return reply.code(404).send({ error: 'User not found' });
}
return user;
} catch (error) {
// Transform database errors
if (error.code === 'CONNECTION_ERROR') {
request.log.error({ err: error }, 'Database connection failed');
return reply.code(503).send({ error: 'Service temporarily unavailable' });
}
throw error; // Re-throw for error handler
}
});Hook Error Handling
Errors in hooks are handled the same way:
app.addHook('onRequest', async (request, reply) => {
const token = request.headers.authorization;
if (!token) {
// This error goes to the error handler
throw new UnauthorizedError();
}
try {
request.user = await verifyToken(token);
} catch (error) {
throw new UnauthorizedError();
}
});
// Or use reply to send response directly
app.addHook('onRequest', async (request, reply) => {
if (!request.headers.authorization) {
reply.code(401).send({ error: 'Unauthorized' });
return; // Must return to stop processing
}
});Not Found Handler
Customize the 404 response:
app.setNotFoundHandler(async (request, reply) => {
return reply.code(404).send({
statusCode: 404,
error: 'Not Found',
message: `Route ${request.method} ${request.url} not found`,
});
});
// With schema validation
app.setNotFoundHandler({
preValidation: async (request, reply) => {
// Pre-validation hook for 404 handler
},
}, async (request, reply) => {
return reply.code(404).send({ error: 'Not Found' });
});Error Wrapping
Wrap external errors with context:
import createError from '@fastify/error';
const DatabaseError = createError('DATABASE_ERROR', 'Database operation failed: %s', 500);
const ExternalServiceError = createError('EXTERNAL_SERVICE_ERROR', 'External service failed: %s', 502);
app.get('/users/:id', async (request) => {
try {
return await db.users.findById(request.params.id);
} catch (error) {
throw new DatabaseError(error.message, { cause: error });
}
});
app.get('/weather', async (request) => {
try {
return await weatherApi.fetch(request.query.city);
} catch (error) {
throw new ExternalServiceError(error.message, { cause: error });
}
});Validation Error Customization
Customize validation error format:
app.setErrorHandler((error, request, reply) => {
if (error.validation) {
const details = error.validation.map((err) => {
const field = err.instancePath
? err.instancePath.slice(1).replace(/\//g, '.')
: err.params?.missingProperty || 'unknown';
return {
field,
message: err.message,
value: err.data,
};
});
return reply.code(400).send({
statusCode: 400,
error: 'Validation Error',
message: `Invalid ${error.validationContext}: ${details.map(d => d.field).join(', ')}`,
details,
});
}
// Handle other errors...
throw error;
});Error Cause Chain
Preserve error chains for debugging:
app.get('/complex-operation', async (request) => {
try {
await step1();
} catch (error) {
const wrapped = new Error('Step 1 failed', { cause: error });
wrapped.statusCode = 500;
throw wrapped;
}
});
// In error handler, log the full chain
app.setErrorHandler((error, request, reply) => {
// Log error with cause chain
let current = error;
const chain = [];
while (current) {
chain.push({
message: current.message,
code: current.code,
stack: current.stack,
});
current = current.cause;
}
request.log.error({ errorChain: chain }, 'Request failed');
reply.code(error.statusCode || 500).send({
error: error.message,
});
});Plugin-Scoped Error Handlers
Set error handlers at the plugin level:
app.register(async function apiRoutes(fastify) {
// This error handler only applies to routes in this plugin
fastify.setErrorHandler((error, request, reply) => {
request.log.error({ err: error }, 'API error');
reply.code(error.statusCode || 500).send({
error: {
code: error.code || 'API_ERROR',
message: error.message,
},
});
});
fastify.get('/data', async () => {
throw new Error('API-specific error');
});
}, { prefix: '/api' });Graceful Error Recovery
Handle errors gracefully without crashing:
app.get('/resilient', async (request, reply) => {
const results = await Promise.allSettled([
fetchPrimaryData(),
fetchSecondaryData(),
fetchOptionalData(),
]);
const [primary, secondary, optional] = results;
if (primary.status === 'rejected') {
// Primary data is required
throw new Error('Primary data unavailable');
}
return {
data: primary.value,
secondary: secondary.status === 'fulfilled' ? secondary.value : null,
optional: optional.status === 'fulfilled' ? optional.value : null,
warnings: results
.filter((r) => r.status === 'rejected')
.map((r) => r.reason.message),
};
});Hooks and Request Lifecycle
Request Lifecycle Overview
Fastify executes hooks in a specific order:
Incoming Request
|
onRequest
|
preParsing
|
preValidation
|
preHandler
|
Handler
|
preSerialization
|
onSend
|
onResponseonRequest Hook
First hook to execute, before body parsing. Use for authentication, request ID setup:
import Fastify from 'fastify';
const app = Fastify();
// Global onRequest hook
app.addHook('onRequest', async (request, reply) => {
request.startTime = Date.now();
request.log.info({ url: request.url, method: request.method }, 'Request started');
});
// Authentication check
app.addHook('onRequest', async (request, reply) => {
// Skip auth for public routes
if (request.url.startsWith('/public')) {
return;
}
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
reply.code(401).send({ error: 'Unauthorized' });
return; // Stop processing
}
try {
request.user = await verifyToken(token);
} catch {
reply.code(401).send({ error: 'Invalid token' });
}
});preParsing Hook
Execute before body parsing. Can modify the payload stream:
app.addHook('preParsing', async (request, reply, payload) => {
// Log raw payload size
request.log.debug({ contentLength: request.headers['content-length'] }, 'Parsing body');
// Return modified payload stream if needed
return payload;
});
// Decompress incoming data
app.addHook('preParsing', async (request, reply, payload) => {
if (request.headers['content-encoding'] === 'gzip') {
return payload.pipe(zlib.createGunzip());
}
return payload;
});preValidation Hook
Execute after parsing, before schema validation:
app.addHook('preValidation', async (request, reply) => {
// Modify body before validation
if (request.body && typeof request.body === 'object') {
// Normalize data
request.body.email = request.body.email?.toLowerCase().trim();
}
});
// Rate limiting check
app.addHook('preValidation', async (request, reply) => {
const key = request.ip;
const count = await redis.incr(`ratelimit:${key}`);
if (count === 1) {
await redis.expire(`ratelimit:${key}`, 60);
}
if (count > 100) {
reply.code(429).send({ error: 'Too many requests' });
}
});preHandler Hook
Most common hook, execute after validation, before handler:
// Authorization check
app.addHook('preHandler', async (request, reply) => {
const { userId } = request.params as { userId: string };
if (request.user.id !== userId && !request.user.isAdmin) {
reply.code(403).send({ error: 'Forbidden' });
}
});
// Load related data
app.addHook('preHandler', async (request, reply) => {
if (request.params?.projectId) {
request.project = await db.projects.findById(request.params.projectId);
if (!request.project) {
reply.code(404).send({ error: 'Project not found' });
}
}
});
// Transaction wrapper
app.addHook('preHandler', async (request) => {
request.transaction = await db.beginTransaction();
});
app.addHook('onResponse', async (request) => {
if (request.transaction) {
await request.transaction.commit();
}
});
app.addHook('onError', async (request, reply, error) => {
if (request.transaction) {
await request.transaction.rollback();
}
});preSerialization Hook
Modify payload before serialization:
app.addHook('preSerialization', async (request, reply, payload) => {
// Add metadata to all responses
if (payload && typeof payload === 'object') {
return {
...payload,
_meta: {
requestId: request.id,
timestamp: new Date().toISOString(),
},
};
}
return payload;
});
// Remove sensitive fields
app.addHook('preSerialization', async (request, reply, payload) => {
if (payload?.user?.password) {
const { password, ...user } = payload.user;
return { ...payload, user };
}
return payload;
});onSend Hook
Modify response after serialization:
app.addHook('onSend', async (request, reply, payload) => {
// Add response headers
reply.header('X-Response-Time', Date.now() - request.startTime);
// Compress response
if (payload && payload.length > 1024) {
const compressed = await gzip(payload);
reply.header('Content-Encoding', 'gzip');
return compressed;
}
return payload;
});
// Transform JSON string response
app.addHook('onSend', async (request, reply, payload) => {
if (reply.getHeader('content-type')?.includes('application/json')) {
// payload is already a string at this point
return payload;
}
return payload;
});onResponse Hook
Execute after response is sent. Cannot modify response:
app.addHook('onResponse', async (request, reply) => {
// Log response time
const responseTime = Date.now() - request.startTime;
request.log.info({
method: request.method,
url: request.url,
statusCode: reply.statusCode,
responseTime,
}, 'Request completed');
// Track metrics
metrics.histogram('http_request_duration', responseTime, {
method: request.method,
route: request.routeOptions.url,
status: reply.statusCode,
});
});onError Hook
Execute when an error is thrown:
app.addHook('onError', async (request, reply, error) => {
// Log error details
request.log.error({
err: error,
url: request.url,
method: request.method,
body: request.body,
}, 'Request error');
// Track error metrics
metrics.increment('http_errors', {
error: error.code || 'UNKNOWN',
route: request.routeOptions.url,
});
// Cleanup resources
if (request.tempFile) {
await fs.unlink(request.tempFile).catch(() => {});
}
});onTimeout Hook
Execute when request times out:
const app = Fastify({
connectionTimeout: 30000, // 30 seconds
});
app.addHook('onTimeout', async (request, reply) => {
request.log.warn({
url: request.url,
method: request.method,
}, 'Request timeout');
// Cleanup
if (request.abortController) {
request.abortController.abort();
}
});onRequestAbort Hook
Execute when client closes connection:
app.addHook('onRequestAbort', async (request) => {
request.log.info('Client aborted request');
// Cancel ongoing operations
if (request.abortController) {
request.abortController.abort();
}
// Cleanup uploaded files
if (request.uploadedFiles) {
for (const file of request.uploadedFiles) {
await fs.unlink(file.path).catch(() => {});
}
}
});Application Lifecycle Hooks
Hooks that run at application startup/shutdown:
// After all plugins are loaded
app.addHook('onReady', async function () {
this.log.info('Server is ready');
// Initialize connections
await this.db.connect();
await this.redis.connect();
// Warm caches
await this.cache.warmup();
});
// When server is closing
app.addHook('onClose', async function () {
this.log.info('Server is closing');
// Cleanup connections
await this.db.close();
await this.redis.disconnect();
});
// After routes are registered
app.addHook('onRoute', (routeOptions) => {
console.log(`Route registered: ${routeOptions.method} ${routeOptions.url}`);
// Track all routes
routes.push({
method: routeOptions.method,
url: routeOptions.url,
schema: routeOptions.schema,
});
});
// After plugin is registered
app.addHook('onRegister', (instance, options) => {
console.log(`Plugin registered with prefix: ${options.prefix}`);
});Scoped Hooks
Hooks are scoped to their encapsulation context:
app.addHook('onRequest', async (request) => {
// Runs for ALL routes
request.log.info('Global hook');
});
app.register(async function adminRoutes(fastify) {
// Only runs for routes in this plugin
fastify.addHook('onRequest', async (request, reply) => {
if (!request.user?.isAdmin) {
reply.code(403).send({ error: 'Admin only' });
}
});
fastify.get('/admin/users', async () => {
return { users: [] };
});
}, { prefix: '/admin' });Hook Execution Order
Multiple hooks of the same type execute in registration order:
app.addHook('onRequest', async () => {
console.log('First');
});
app.addHook('onRequest', async () => {
console.log('Second');
});
app.addHook('onRequest', async () => {
console.log('Third');
});
// Output: First, Second, ThirdStopping Hook Execution
Return early from hooks to stop processing:
app.addHook('preHandler', async (request, reply) => {
if (!request.user) {
// Send response and return to stop further processing
reply.code(401).send({ error: 'Unauthorized' });
return;
}
// Continue to next hook and handler
});Route-Level Hooks
Add hooks to specific routes:
const adminOnlyHook = async (request, reply) => {
if (!request.user?.isAdmin) {
reply.code(403).send({ error: 'Forbidden' });
}
};
app.get('/admin/settings', {
preHandler: [adminOnlyHook],
handler: async (request) => {
return { settings: {} };
},
});
// Multiple hooks
app.post('/orders', {
preValidation: [validateApiKey],
preHandler: [loadUser, checkQuota, logOrder],
handler: createOrderHandler,
});Async Hook Patterns
Always use async/await in hooks:
// GOOD - async hook
app.addHook('preHandler', async (request, reply) => {
const user = await loadUser(request.headers.authorization);
request.user = user;
});
// AVOID - callback style (deprecated)
app.addHook('preHandler', (request, reply, done) => {
loadUser(request.headers.authorization)
.then((user) => {
request.user = user;
done();
})
.catch(done);
});HTTP Proxy and Reply.from()
@fastify/http-proxy
Use @fastify/http-proxy for simple reverse proxy scenarios:
import Fastify from 'fastify';
import httpProxy from '@fastify/http-proxy';
const app = Fastify({ logger: true });
// Proxy all requests to /api/* to another service
app.register(httpProxy, {
upstream: 'http://backend-service:3001',
prefix: '/api',
rewritePrefix: '/v1',
http2: false,
});
// With authentication
app.register(httpProxy, {
upstream: 'http://internal-api:3002',
prefix: '/internal',
preHandler: async (request, reply) => {
// Verify authentication before proxying
if (!request.headers.authorization) {
reply.code(401).send({ error: 'Unauthorized' });
}
},
});
await app.listen({ port: 3000 });@fastify/reply-from
For more control over proxying, use @fastify/reply-from with reply.from():
import Fastify from 'fastify';
import replyFrom from '@fastify/reply-from';
const app = Fastify({ logger: true });
app.register(replyFrom, {
base: 'http://backend-service:3001',
http2: false,
});
// Proxy with request/response manipulation
app.get('/users/:id', async (request, reply) => {
const { id } = request.params;
return reply.from(`/api/users/${id}`, {
// Modify request before forwarding
rewriteRequestHeaders: (originalReq, headers) => ({
...headers,
'x-request-id': request.id,
'x-forwarded-for': request.ip,
}),
// Modify response before sending
onResponse: (request, reply, res) => {
reply.header('x-proxy', 'fastify');
reply.send(res);
},
});
});
// Conditional routing
app.all('/api/*', async (request, reply) => {
const upstream = selectUpstream(request);
return reply.from(request.url, {
base: upstream,
});
});
function selectUpstream(request) {
// Route to different backends based on request
if (request.headers['x-beta']) {
return 'http://beta-backend:3001';
}
return 'http://stable-backend:3001';
}API Gateway Pattern
Build an API gateway with multiple backends:
import Fastify from 'fastify';
import replyFrom from '@fastify/reply-from';
const app = Fastify({ logger: true });
// Configure multiple upstreams
const services = {
users: 'http://users-service:3001',
orders: 'http://orders-service:3002',
products: 'http://products-service:3003',
};
app.register(replyFrom);
// Route to user service
app.register(async function (fastify) {
fastify.all('/*', async (request, reply) => {
return reply.from(request.url.replace('/users', ''), {
base: services.users,
});
});
}, { prefix: '/users' });
// Route to orders service
app.register(async function (fastify) {
fastify.all('/*', async (request, reply) => {
return reply.from(request.url.replace('/orders', ''), {
base: services.orders,
});
});
}, { prefix: '/orders' });
// Route to products service
app.register(async function (fastify) {
fastify.all('/*', async (request, reply) => {
return reply.from(request.url.replace('/products', ''), {
base: services.products,
});
});
}, { prefix: '/products' });Request Body Handling
Handle request bodies when proxying:
app.post('/api/data', async (request, reply) => {
return reply.from('/data', {
body: request.body,
contentType: request.headers['content-type'],
});
});
// Stream large bodies
app.post('/upload', async (request, reply) => {
return reply.from('/upload', {
body: request.raw,
contentType: request.headers['content-type'],
});
});Error Handling
Handle upstream errors gracefully:
app.register(replyFrom, {
base: 'http://backend:3001',
// Called when upstream returns an error
onError: (reply, error) => {
reply.log.error({ err: error }, 'Proxy error');
reply.code(502).send({
error: 'Bad Gateway',
message: 'Upstream service unavailable',
});
},
});
// Custom error handling per route
app.get('/data', async (request, reply) => {
try {
return await reply.from('/data');
} catch (error) {
request.log.error({ err: error }, 'Failed to proxy request');
return reply.code(503).send({
error: 'Service Unavailable',
retryAfter: 30,
});
}
});WebSocket Proxying
Proxy WebSocket connections:
import Fastify from 'fastify';
import httpProxy from '@fastify/http-proxy';
const app = Fastify({ logger: true });
app.register(httpProxy, {
upstream: 'http://ws-backend:3001',
prefix: '/ws',
websocket: true,
});Timeout Configuration
Configure proxy timeouts:
app.register(replyFrom, {
base: 'http://backend:3001',
http: {
requestOptions: {
timeout: 30000, // 30 seconds
},
},
});Caching Proxied Responses
Add caching to proxied responses:
import { createCache } from 'async-cache-dedupe';
const cache = createCache({
ttl: 60,
storage: { type: 'memory' },
});
cache.define('proxyGet', async (url: string) => {
const response = await fetch(`http://backend:3001${url}`);
return response.json();
});
app.get('/cached/*', async (request, reply) => {
const data = await cache.proxyGet(request.url);
return data;
});Logging with Pino
Built-in Pino Integration
Fastify uses Pino for high-performance logging:
import Fastify from 'fastify';
const app = Fastify({
logger: true, // Enable default logging
});
// Or with configuration
const app = Fastify({
logger: {
level: 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
},
},
},
});Log Levels
Available log levels (in order of severity):
app.log.trace('Detailed debugging');
app.log.debug('Debugging information');
app.log.info('General information');
app.log.warn('Warning messages');
app.log.error('Error messages');
app.log.fatal('Fatal errors');Request-Scoped Logging
Each request has its own logger with request context:
app.get('/users/:id', async (request) => {
// Logs include request ID automatically
request.log.info('Fetching user');
const user = await db.users.findById(request.params.id);
if (!user) {
request.log.warn({ userId: request.params.id }, 'User not found');
return { error: 'Not found' };
}
request.log.info({ userId: user.id }, 'User fetched');
return user;
});Structured Logging
Always use structured logging with objects:
// GOOD - structured, searchable
request.log.info({
action: 'user_created',
userId: user.id,
email: user.email,
}, 'User created successfully');
request.log.error({
err: error,
userId: request.params.id,
operation: 'fetch_user',
}, 'Failed to fetch user');
// BAD - unstructured, hard to parse
request.log.info(`User ${user.id} created with email ${user.email}`);
request.log.error(`Failed to fetch user: ${error.message}`);Logging Configuration by Environment
function getLoggerConfig() {
if (process.env.NODE_ENV === 'production') {
return {
level: 'info',
// JSON output for log aggregation
};
}
if (process.env.NODE_ENV === 'test') {
return false; // Disable logging in tests
}
// Development
return {
level: 'debug',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
},
};
}
const app = Fastify({
logger: getLoggerConfig(),
});Custom Serializers
Customize how objects are serialized:
const app = Fastify({
logger: {
level: 'info',
serializers: {
// Customize request serialization
req: (request) => ({
method: request.method,
url: request.url,
headers: {
host: request.headers.host,
'user-agent': request.headers['user-agent'],
},
remoteAddress: request.ip,
}),
// Customize response serialization
res: (response) => ({
statusCode: response.statusCode,
}),
// Custom serializer for users
user: (user) => ({
id: user.id,
email: user.email,
// Exclude sensitive fields
}),
},
},
});
// Use custom serializer
request.log.info({ user: request.user }, 'User action');Redacting Sensitive Data
Prevent logging sensitive information:
import Fastify from 'fastify';
const app = Fastify({
logger: {
level: 'info',
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'body.password',
'body.creditCard',
'*.password',
'*.secret',
'*.token',
],
censor: '[REDACTED]',
},
},
});Child Loggers
Create child loggers with additional context:
app.addHook('onRequest', async (request) => {
// Add user context to all logs for this request
if (request.user) {
request.log = request.log.child({
userId: request.user.id,
userRole: request.user.role,
});
}
});
// Service-level child logger
const userService = {
log: app.log.child({ service: 'UserService' }),
async create(data) {
this.log.info({ email: data.email }, 'Creating user');
// ...
},
};Request Logging Configuration
Customize automatic request logging:
const app = Fastify({
logger: true,
disableRequestLogging: true, // Disable default request/response logs
});
// Custom request logging
app.addHook('onRequest', async (request) => {
request.log.info({
method: request.method,
url: request.url,
query: request.query,
}, 'Request received');
});
app.addHook('onResponse', async (request, reply) => {
request.log.info({
statusCode: reply.statusCode,
responseTime: reply.elapsedTime,
}, 'Request completed');
});Logging Errors
Properly log errors with stack traces:
app.setErrorHandler((error, request, reply) => {
// Log error with full details
request.log.error({
err: error, // Pino serializes error objects properly
url: request.url,
method: request.method,
body: request.body,
query: request.query,
}, 'Request error');
reply.code(error.statusCode || 500).send({
error: error.message,
});
});
// In handlers
app.get('/data', async (request) => {
try {
return await fetchData();
} catch (error) {
request.log.error({ err: error }, 'Failed to fetch data');
throw error;
}
});Log Destinations
Configure where logs are sent:
import { createWriteStream } from 'node:fs';
// File output
const app = Fastify({
logger: {
level: 'info',
stream: createWriteStream('./app.log'),
},
});
// Multiple destinations with pino.multistream
import pino from 'pino';
const streams = [
{ stream: process.stdout },
{ stream: createWriteStream('./app.log') },
{ level: 'error', stream: createWriteStream('./error.log') },
];
const app = Fastify({
logger: pino({ level: 'info' }, pino.multistream(streams)),
});Log Rotation
Use pino-roll for log rotation:
node app.js | pino-roll --frequency daily --extension .logOr configure programmatically:
import { createStream } from 'rotating-file-stream';
const stream = createStream('app.log', {
size: '10M', // Rotate every 10MB
interval: '1d', // Rotate daily
compress: 'gzip',
path: './logs',
});
const app = Fastify({
logger: {
level: 'info',
stream,
},
});Log Aggregation
Format logs for aggregation services:
// For ELK Stack, Datadog, etc. - use default JSON format
const app = Fastify({
logger: {
level: 'info',
// Default JSON output works with most log aggregators
},
});
// Add service metadata
const app = Fastify({
logger: {
level: 'info',
base: {
service: 'user-api',
version: process.env.APP_VERSION,
environment: process.env.NODE_ENV,
},
},
});Request ID Tracking
Use request IDs for distributed tracing:
const app = Fastify({
logger: true,
requestIdHeader: 'x-request-id', // Use incoming header
genReqId: (request) => {
// Generate ID if not provided
return request.headers['x-request-id'] || crypto.randomUUID();
},
});
// Forward request ID to downstream services
app.addHook('onRequest', async (request) => {
request.requestId = request.id;
});
// Include in outgoing requests
const response = await fetch('http://other-service/api', {
headers: {
'x-request-id': request.id,
},
});Performance Considerations
Pino is fast, but consider:
// Avoid string concatenation in log calls
// BAD
request.log.info('User ' + user.id + ' did ' + action);
// GOOD
request.log.info({ userId: user.id, action }, 'User action');
// Use appropriate log levels
// Don't log at info level in hot paths
if (app.log.isLevelEnabled('debug')) {
request.log.debug({ details: expensiveToCompute() }, 'Debug info');
}Performance Optimization
Fastify is Fast by Default
Fastify is designed for performance. Key optimizations are built-in:
- Fast JSON serialization with
fast-json-stringify - Efficient routing with
find-my-way - Schema-based validation with
ajv(compiled validators) - Low overhead request/response handling
Use @fastify/under-pressure for Load Shedding
Protect your application from overload with @fastify/under-pressure:
import underPressure from '@fastify/under-pressure';
app.register(underPressure, {
maxEventLoopDelay: 1000, // Max event loop delay in ms
maxHeapUsedBytes: 1000000000, // Max heap used (~1GB)
maxRssBytes: 1500000000, // Max RSS (~1.5GB)
maxEventLoopUtilization: 0.98, // Max event loop utilization
pressureHandler: (request, reply, type, value) => {
reply.code(503).send({
error: 'Service Unavailable',
message: `Server under pressure: ${type}`,
});
},
});
// Health check that respects pressure
app.get('/health', async (request, reply) => {
return { status: 'ok' };
});Always Define Response Schemas
Response schemas enable fast-json-stringify, which is significantly faster than JSON.stringify:
// FAST - uses fast-json-stringify
app.get('/users', {
schema: {
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
},
},
},
},
},
}, async () => {
return db.users.findAll();
});
// SLOW - uses JSON.stringify
app.get('/users-slow', async () => {
return db.users.findAll();
});Avoid Dynamic Schema Compilation
Add schemas at startup, not at request time:
// GOOD - schemas compiled at startup
app.addSchema({ $id: 'user', ... });
app.get('/users', {
schema: { response: { 200: { $ref: 'user#' } } },
}, handler);
// BAD - schema compiled per request
app.get('/users', async (request, reply) => {
const schema = getSchemaForUser(request.user);
// This is slow!
});Use Logger Wisely
Pino is fast, but excessive logging has overhead:
import Fastify from 'fastify';
// Set log level via environment variable
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
},
});
// Avoid logging large objects
app.get('/data', async (request) => {
// BAD - logs entire payload
request.log.info({ data: largeObject }, 'Processing');
// GOOD - log only what's needed
request.log.info({ id: largeObject.id }, 'Processing');
return largeObject;
});Connection Pooling
Use connection pools for databases:
import postgres from 'postgres';
// Create pool at startup
const sql = postgres(process.env.DATABASE_URL, {
max: 20, // Maximum pool size
idle_timeout: 20,
connect_timeout: 10,
});
app.decorate('db', sql);
// Connections are reused
app.get('/users', async () => {
return app.db`SELECT * FROM users LIMIT 100`;
});Avoid Blocking the Event Loop
Use piscina for CPU-intensive operations. It provides a robust worker thread pool:
import Piscina from 'piscina';
import { join } from 'node:path';
const piscina = new Piscina({
filename: join(import.meta.dirname, 'workers', 'compute.js'),
});
app.post('/compute', async (request) => {
const result = await piscina.run(request.body);
return result;
});// workers/compute.js
export default function compute(data) {
// CPU-intensive work here
return processedResult;
}Stream Large Responses
Stream large payloads instead of buffering:
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
// GOOD - stream file
app.get('/large-file', async (request, reply) => {
const stream = createReadStream('./large-file.json');
reply.type('application/json');
return reply.send(stream);
});
// BAD - load entire file into memory
app.get('/large-file-bad', async () => {
const content = await fs.readFile('./large-file.json', 'utf-8');
return JSON.parse(content);
});
// Stream database results
app.get('/export', async (request, reply) => {
reply.type('application/json');
const cursor = db.users.findCursor();
reply.raw.write('[');
let first = true;
for await (const user of cursor) {
if (!first) reply.raw.write(',');
reply.raw.write(JSON.stringify(user));
first = false;
}
reply.raw.write(']');
reply.raw.end();
});Caching Strategies
Implement caching for expensive operations:
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, unknown>({
max: 1000,
ttl: 60000, // 1 minute
});
app.get('/expensive/:id', async (request) => {
const { id } = request.params;
const cacheKey = `expensive:${id}`;
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const result = await expensiveOperation(id);
cache.set(cacheKey, result);
return result;
});
// Cache control headers
app.get('/static-data', async (request, reply) => {
reply.header('Cache-Control', 'public, max-age=3600');
return { data: 'static' };
});Request Coalescing with async-cache-dedupe
Use async-cache-dedupe for deduplicating concurrent identical requests and caching:
import { createCache } from 'async-cache-dedupe';
const cache = createCache({
ttl: 60, // seconds
stale: 5, // serve stale while revalidating
storage: { type: 'memory' },
});
cache.define('fetchData', async (id: string) => {
return db.findById(id);
});
app.get('/data/:id', async (request) => {
const { id } = request.params;
// Automatically deduplicates concurrent requests for the same id
// and caches the result
return cache.fetchData(id);
});For distributed caching, use Redis storage:
import { createCache } from 'async-cache-dedupe';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const cache = createCache({
ttl: 60,
storage: { type: 'redis', options: { client: redis } },
});Payload Limits
Set appropriate payload limits:
import Fastify from 'fastify';
const app = Fastify({
bodyLimit: 1048576, // 1MB default
});
// Per-route limit for file uploads
app.post('/upload', {
bodyLimit: 10485760, // 10MB for this route
}, uploadHandler);Compression
Use compression for responses:
import fastifyCompress from '@fastify/compress';
app.register(fastifyCompress, {
global: true,
threshold: 1024, // Only compress responses > 1KB
encodings: ['gzip', 'deflate'],
});
// Disable for specific route
app.get('/already-compressed', {
compress: false,
}, handler);Connection Timeouts
Configure appropriate timeouts:
import Fastify from 'fastify';
const app = Fastify({
connectionTimeout: 30000, // 30 seconds
keepAliveTimeout: 5000, // 5 seconds
});
// Per-route timeout
app.get('/long-operation', {
config: {
timeout: 60000, // 60 seconds
},
}, async (request) => {
return longOperation();
});Disable Unnecessary Features
Disable features you don't need:
import Fastify from 'fastify';
const app = Fastify({
disableRequestLogging: true, // If you don't need request logs
trustProxy: false, // If not behind proxy
caseSensitive: true, // Enable for slight performance gain
ignoreDuplicateSlashes: false,
});Benchmarking
Use autocannon for load testing:
# Install
npm install -g autocannon
# Basic benchmark
autocannon http://localhost:3000/api/users
# With options
autocannon -c 100 -d 30 -p 10 http://localhost:3000/api/users
# -c: connections
# -d: duration in seconds
# -p: pipelining factor// Programmatic benchmarking
import autocannon from 'autocannon';
const result = await autocannon({
url: 'http://localhost:3000/api/users',
connections: 100,
duration: 30,
pipelining: 10,
});
console.log(autocannon.printResult(result));Profiling
Use @platformatic/flame for flame graph profiling:
npx @platformatic/flame app.jsThis generates an interactive flame graph to identify performance bottlenecks.
Memory Management
Monitor and optimize memory usage:
// Add health endpoint with memory info
app.get('/health', async () => {
const memory = process.memoryUsage();
return {
status: 'ok',
memory: {
heapUsed: Math.round(memory.heapUsed / 1024 / 1024) + 'MB',
heapTotal: Math.round(memory.heapTotal / 1024 / 1024) + 'MB',
rss: Math.round(memory.rss / 1024 / 1024) + 'MB',
},
};
});
// Avoid memory leaks in closures
app.addHook('onRequest', async (request) => {
// BAD - holding reference to large object
const largeData = await loadLargeData();
request.getData = () => largeData;
// GOOD - load on demand
request.getData = () => loadLargeData();
});Plugin Development and Encapsulation
Understanding Encapsulation
Fastify's plugin system provides automatic encapsulation. Each plugin creates its own context, isolating decorators, hooks, and plugins registered within it:
import Fastify from 'fastify';
import fp from 'fastify-plugin';
const app = Fastify();
// This plugin is encapsulated - its decorators are NOT available to siblings
app.register(async function childPlugin(fastify) {
fastify.decorate('privateUtil', () => 'only available here');
// This decorator is only available within this plugin and its children
fastify.get('/child', async function (request, reply) {
return this.privateUtil();
});
});
// This route CANNOT access privateUtil - it's in a different context
app.get('/parent', async function (request, reply) {
// this.privateUtil is undefined here
return { status: 'ok' };
});Breaking Encapsulation with fastify-plugin
Use fastify-plugin when you need to share decorators, hooks, or plugins with the parent context:
import fp from 'fastify-plugin';
// This plugin's decorators will be available to the parent and siblings
export default fp(async function databasePlugin(fastify, options) {
const db = await createConnection(options.connectionString);
fastify.decorate('db', db);
fastify.addHook('onClose', async () => {
await db.close();
});
}, {
name: 'database-plugin',
dependencies: [], // List plugin dependencies
});Plugin Registration Order
Plugins are registered in order, but loading is asynchronous. Use after() for sequential dependencies:
import Fastify from 'fastify';
import databasePlugin from './plugins/database.js';
import authPlugin from './plugins/auth.js';
import routesPlugin from './routes/index.js';
const app = Fastify();
// Database must be ready before auth
app.register(databasePlugin);
// Auth depends on database
app.register(authPlugin);
// Routes depend on both
app.register(routesPlugin);
// Or use after() for explicit sequencing
app.register(databasePlugin).after(() => {
app.register(authPlugin).after(() => {
app.register(routesPlugin);
});
});
await app.ready();Plugin Options
Always validate and document plugin options:
import fp from 'fastify-plugin';
interface CachePluginOptions {
ttl: number;
maxSize?: number;
prefix?: string;
}
export default fp<CachePluginOptions>(async function cachePlugin(fastify, options) {
const { ttl, maxSize = 1000, prefix = 'cache:' } = options;
if (typeof ttl !== 'number' || ttl <= 0) {
throw new Error('Cache plugin requires a positive ttl option');
}
const cache = new Map<string, { value: unknown; expires: number }>();
fastify.decorate('cache', {
get(key: string): unknown | undefined {
const item = cache.get(prefix + key);
if (!item) return undefined;
if (Date.now() > item.expires) {
cache.delete(prefix + key);
return undefined;
}
return item.value;
},
set(key: string, value: unknown): void {
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(prefix + key, { value, expires: Date.now() + ttl });
},
});
}, {
name: 'cache-plugin',
});Plugin Factory Pattern
Create configurable plugins using factory functions:
import fp from 'fastify-plugin';
interface RateLimitOptions {
max: number;
timeWindow: number;
}
function createRateLimiter(defaults: Partial<RateLimitOptions> = {}) {
return fp<RateLimitOptions>(async function rateLimitPlugin(fastify, options) {
const config = { ...defaults, ...options };
// Implementation
fastify.decorate('rateLimit', config);
}, {
name: 'rate-limiter',
});
}
// Usage
app.register(createRateLimiter({ max: 100 }), { timeWindow: 60000 });Plugin Dependencies
Declare dependencies to ensure proper load order:
import fp from 'fastify-plugin';
export default fp(async function authPlugin(fastify) {
// This plugin requires 'database-plugin' to be loaded first
if (!fastify.hasDecorator('db')) {
throw new Error('Auth plugin requires database plugin');
}
fastify.decorate('authenticate', async (request) => {
const user = await fastify.db.users.findByToken(request.headers.authorization);
return user;
});
}, {
name: 'auth-plugin',
dependencies: ['database-plugin'],
});Scoped Plugins for Route Groups
Use encapsulation to scope plugins to specific routes:
import Fastify from 'fastify';
const app = Fastify();
// Public routes - no auth required
app.register(async function publicRoutes(fastify) {
fastify.get('/health', async () => ({ status: 'ok' }));
fastify.get('/docs', async () => ({ version: '1.0.0' }));
});
// Protected routes - auth required
app.register(async function protectedRoutes(fastify) {
// Auth hook only applies to routes in this plugin
fastify.addHook('onRequest', async (request, reply) => {
const token = request.headers.authorization;
if (!token) {
reply.code(401).send({ error: 'Unauthorized' });
return;
}
request.user = await verifyToken(token);
});
fastify.get('/profile', async (request) => {
return { user: request.user };
});
fastify.get('/settings', async (request) => {
return { settings: await getSettings(request.user.id) };
});
});Prefix Routes with Register
Use the prefix option to namespace routes:
app.register(import('./routes/users.js'), { prefix: '/api/v1/users' });
app.register(import('./routes/posts.js'), { prefix: '/api/v1/posts' });
// In routes/users.js
export default async function userRoutes(fastify) {
// Becomes /api/v1/users
fastify.get('/', async () => {
return { users: [] };
});
// Becomes /api/v1/users/:id
fastify.get('/:id', async (request) => {
return { user: { id: request.params.id } };
});
}Plugin Metadata
Add metadata for documentation and tooling:
import fp from 'fastify-plugin';
async function metricsPlugin(fastify) {
// Implementation
}
export default fp(metricsPlugin, {
name: 'metrics-plugin',
fastify: '5.x', // Fastify version compatibility
dependencies: ['pino-plugin'],
decorators: {
fastify: ['db'], // Required decorators
request: [],
reply: [],
},
});Autoload Plugins
Use @fastify/autoload for automatic plugin loading:
import Fastify from 'fastify';
import autoload from '@fastify/autoload';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = Fastify();
// Load all plugins from the plugins directory
app.register(autoload, {
dir: join(__dirname, 'plugins'),
options: { prefix: '/api' },
});
// Load all routes from the routes directory
app.register(autoload, {
dir: join(__dirname, 'routes'),
options: { prefix: '/api' },
});Testing Plugins in Isolation
Test plugins independently:
import { describe, it, before, after } from 'node:test';
import Fastify from 'fastify';
import myPlugin from './my-plugin.js';
describe('MyPlugin', () => {
let app;
before(async () => {
app = Fastify();
app.register(myPlugin, { option: 'value' });
await app.ready();
});
after(async () => {
await app.close();
});
it('should decorate fastify instance', (t) => {
t.assert.ok(app.hasDecorator('myDecorator'));
});
});Route Organization and Handlers
Basic Route Definition
Define routes with the shorthand methods or the full route method:
import Fastify from 'fastify';
const app = Fastify();
// Shorthand methods
app.get('/users', async (request, reply) => {
return { users: [] };
});
app.post('/users', async (request, reply) => {
return { created: true };
});
// Full route method with all options
app.route({
method: 'GET',
url: '/users/:id',
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
},
},
handler: async (request, reply) => {
return { id: request.params.id };
},
});Route Parameters
Access URL parameters through request.params:
// Single parameter
app.get('/users/:id', async (request) => {
const { id } = request.params as { id: string };
return { userId: id };
});
// Multiple parameters
app.get('/users/:userId/posts/:postId', async (request) => {
const { userId, postId } = request.params as { userId: string; postId: string };
return { userId, postId };
});
// Wildcard parameter (captures everything after)
app.get('/files/*', async (request) => {
const path = (request.params as { '*': string })['*'];
return { filePath: path };
});
// Regex parameters (Fastify uses find-my-way)
app.get('/orders/:id(\\d+)', async (request) => {
// Only matches numeric IDs
const { id } = request.params as { id: string };
return { orderId: parseInt(id, 10) };
});Query String Parameters
Access query parameters through request.query:
app.get('/search', {
schema: {
querystring: {
type: 'object',
properties: {
q: { type: 'string' },
page: { type: 'integer', default: 1 },
limit: { type: 'integer', default: 10, maximum: 100 },
},
required: ['q'],
},
},
handler: async (request) => {
const { q, page, limit } = request.query as {
q: string;
page: number;
limit: number;
};
return { query: q, page, limit };
},
});Request Body
Access the request body through request.body:
app.post('/users', {
schema: {
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 0 },
},
required: ['name', 'email'],
},
},
handler: async (request, reply) => {
const user = request.body as { name: string; email: string; age?: number };
// Create user...
reply.code(201);
return { user };
},
});Headers
Access request headers through request.headers:
app.get('/protected', {
schema: {
headers: {
type: 'object',
properties: {
authorization: { type: 'string' },
},
required: ['authorization'],
},
},
handler: async (request) => {
const token = request.headers.authorization;
return { authenticated: true };
},
});Reply Methods
Use reply methods to control the response:
app.get('/examples', async (request, reply) => {
// Set status code
reply.code(201);
// Set headers
reply.header('X-Custom-Header', 'value');
reply.headers({ 'X-Another': 'value', 'X-Third': 'value' });
// Set content type
reply.type('application/json');
// Redirect
// reply.redirect('/other-url');
// reply.redirect(301, '/permanent-redirect');
// Return response (automatic serialization)
return { status: 'ok' };
});
// Explicit send (useful in non-async handlers)
app.get('/explicit', (request, reply) => {
reply.send({ status: 'ok' });
});
// Stream response
app.get('/stream', async (request, reply) => {
const stream = fs.createReadStream('./large-file.txt');
reply.type('text/plain');
return reply.send(stream);
});Route Organization by Feature
Organize routes by feature/domain in separate files:
src/
routes/
users/
index.ts # Route definitions
handlers.ts # Handler functions
schemas.ts # JSON schemas
posts/
index.ts
handlers.ts
schemas.ts// routes/users/schemas.ts
export const userSchema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
};
export const createUserSchema = {
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
},
response: {
201: userSchema,
},
};
// routes/users/handlers.ts
import type { FastifyRequest, FastifyReply } from 'fastify';
export async function createUser(
request: FastifyRequest<{ Body: { name: string; email: string } }>,
reply: FastifyReply,
) {
const { name, email } = request.body;
const user = await request.server.db.users.create({ name, email });
reply.code(201);
return user;
}
export async function getUsers(request: FastifyRequest) {
return request.server.db.users.findAll();
}
// routes/users/index.ts
import type { FastifyInstance } from 'fastify';
import { createUser, getUsers } from './handlers.js';
import { createUserSchema } from './schemas.js';
export default async function userRoutes(fastify: FastifyInstance) {
fastify.get('/', getUsers);
fastify.post('/', { schema: createUserSchema }, createUser);
}Route Constraints
Add constraints to routes for versioning or host-based routing:
// Version constraint
app.get('/users', {
constraints: { version: '1.0.0' },
handler: async () => ({ version: '1.0.0', users: [] }),
});
app.get('/users', {
constraints: { version: '2.0.0' },
handler: async () => ({ version: '2.0.0', data: { users: [] } }),
});
// Client sends: Accept-Version: 1.0.0
// Host constraint
app.get('/', {
constraints: { host: 'api.example.com' },
handler: async () => ({ api: true }),
});
app.get('/', {
constraints: { host: 'www.example.com' },
handler: async () => ({ web: true }),
});Route Prefixing
Use prefixes to namespace routes:
// Using register
app.register(async function (fastify) {
fastify.get('/list', async () => ({ users: [] }));
fastify.get('/:id', async (request) => ({ id: request.params.id }));
}, { prefix: '/users' });
// Results in:
// GET /users/list
// GET /users/:idMultiple Methods
Handle multiple HTTP methods with one handler:
app.route({
method: ['GET', 'HEAD'],
url: '/resource',
handler: async (request) => {
return { data: 'resource' };
},
});404 Handler
Customize the not found handler:
app.setNotFoundHandler({
preValidation: async (request, reply) => {
// Optional pre-validation hook
},
preHandler: async (request, reply) => {
// Optional pre-handler hook
},
}, async (request, reply) => {
reply.code(404);
return {
error: 'Not Found',
message: `Route ${request.method} ${request.url} not found`,
statusCode: 404,
};
});Method Not Allowed
Handle method not allowed responses:
// Fastify doesn't have built-in 405 handling
// Implement with a custom not found handler that checks allowed methods
app.setNotFoundHandler(async (request, reply) => {
// Check if the URL exists with a different method
const route = app.hasRoute({
url: request.url,
method: 'GET', // Check other methods
});
if (route) {
reply.code(405);
return { error: 'Method Not Allowed' };
}
reply.code(404);
return { error: 'Not Found' };
});Route-Level Configuration
Apply configuration to specific routes:
app.get('/slow-operation', {
config: {
rateLimit: { max: 10, timeWindow: '1 minute' },
},
handler: async (request) => {
return { result: await slowOperation() };
},
});
// Access config in hooks
app.addHook('onRequest', async (request, reply) => {
const config = request.routeOptions.config;
if (config.rateLimit) {
// Apply rate limiting
}
});Async Route Registration
Register routes from async sources:
app.register(async function (fastify) {
const routeConfigs = await loadRoutesFromDatabase();
for (const config of routeConfigs) {
fastify.route({
method: config.method,
url: config.path,
handler: createDynamicHandler(config),
});
}
});Auto-loading Routes with @fastify/autoload
Use @fastify/autoload to automatically load routes from a directory structure:
import Fastify from 'fastify';
import autoload from '@fastify/autoload';
import { join } from 'node:path';
const app = Fastify({ logger: true });
// Auto-load plugins
app.register(autoload, {
dir: join(import.meta.dirname, 'plugins'),
options: { prefix: '' },
});
// Auto-load routes
app.register(autoload, {
dir: join(import.meta.dirname, 'routes'),
options: { prefix: '/api' },
});
await app.listen({ port: 3000 });Directory structure:
src/
plugins/
database.ts # Loaded automatically
auth.ts # Loaded automatically
routes/
users/
index.ts # GET/POST /api/users
_id/
index.ts # GET/PUT/DELETE /api/users/:id
posts/
index.ts # GET/POST /api/postsRoute file example:
// routes/users/index.ts
import type { FastifyPluginAsync } from 'fastify';
const users: FastifyPluginAsync = async (fastify) => {
fastify.get('/', async () => {
return fastify.repositories.users.findAll();
});
fastify.post('/', async (request) => {
return fastify.repositories.users.create(request.body);
});
};
export default users;{
"name": "mcollina/fastify-best-practices",
"version": "0.1.0",
"private": false,
"summary": "Guides development of Fastify Node.js backend servers and REST APIs using TypeScript or JavaScript. Use when building, configuring, or debugging a Fastify application — including defining routes, implementing plugins, setting up JSON Schema validation, handling errors, optimising performance, managing authentication, configuring CORS and security headers, integrating databases, working with WebSockets, and deploying to production. Covers the full Fastify request lifecycle (hooks, serialization, logging with Pino) and TypeScript integration via strip types. Trigger terms: Fastify, Node.js server, REST API, API routes, backend framework, fastify.config, server.ts, app.ts.",
"skills": {
"fastify-best-practices": {
"path": "SKILL.md"
}
}
}
Related skills
How it compares
Opinionated Fastify JWT recipe skill—not a generic OpenAPI generator or a full OAuth provider integration.
FAQ
Who is fastify-best-practices for?
backend devs on Fastify who need JWT authentication and route guards without reading the entire Fastify ecosystem docs first.
When should I use fastify-best-practices?
In Build when adding auth plugins and login routes; in Ship/security when reviewing JWT expiry, refresh tokens, and 401 handling before launch.
Is fastify-best-practices safe to install?
Examples use environment-backed secrets and standard verify flows—review the Security Audits panel on this page and never commit JWT_SECRET or paste real credentials into prompts.