CtrlK
BlogDocsLog inGet started
Tessl Logo

mcollina/fastify-best-practices

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.

95

Quality

95%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

hooks.mdrules/

name:
hooks
description:
Hooks and request lifecycle in Fastify
metadata:
{"tags":"hooks, lifecycle, middleware, onRequest, preHandler"}

Hooks and Request Lifecycle

Request Lifecycle Overview

Fastify executes hooks in a specific order:

Incoming Request
       |
   onRequest
       |
   preParsing
       |
   preValidation
       |
   preHandler
       |
     Handler
       |
   preSerialization
       |
    onSend
       |
   onResponse

onRequest 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, Third

Stopping 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);
});

rules

authentication.md

configuration.md

content-type.md

cors-security.md

database.md

decorators.md

deployment.md

error-handling.md

hooks.md

http-proxy.md

logging.md

performance.md

plugins.md

routes.md

schemas.md

serialization.md

testing.md

typescript.md

websockets.md

SKILL.md

tile.json