CtrlK
BlogDocsLog inGet started
Tessl Logo

fastify-project-starter

Scaffold a production-ready Fastify 5.x API with TypeScript, plugin architecture, JSON Schema validation, and auto-generated Swagger docs.

79

Quality

71%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Optimize this skill with Tessl

npx tessl skill review --optimize ./backend-node/fastify-project-starter/SKILL.md
SKILL.md
Quality
Evals
Security

Fastify Project Starter

Scaffold a production-ready Fastify 5.x API with TypeScript, plugin architecture, JSON Schema validation, and auto-generated Swagger docs.

Prerequisites

  • Node.js >= 20.x
  • npm >= 10.x (or pnpm >= 9.x)
  • TypeScript >= 5.3

Scaffold Command

mkdir <project-name> && cd <project-name>
pnpm init
pnpm add fastify @fastify/autoload @fastify/sensible @fastify/cors @fastify/helmet @fastify/swagger @fastify/swagger-ui @fastify/env @fastify/type-provider-typebox @sinclair/typebox
pnpm add -D typescript @types/node tsx tsup

# Generate tsconfig
npx tsc --init --strict --target ES2022 --module NodeNext \
  --moduleResolution NodeNext --outDir dist --rootDir src \
  --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames

mkdir -p src/{plugins,routes,services,schemas,types}

Project Structure

src/
  server.ts                  # Entry point — builds and starts the server
  app.ts                     # App factory — registers plugins and routes
  plugins/
    sensible.ts              # @fastify/sensible — httpErrors, to, assert
    cors.ts                  # CORS configuration
    swagger.ts               # Swagger + Swagger UI setup
    env.ts                   # Typed env config via @fastify/env
    auth.ts                  # Auth decorator (e.g. JWT verification)
  routes/
    users/
      index.ts               # Auto-loaded route plugin — GET/POST/PATCH/DELETE
      schema.ts              # TypeBox schemas for this resource
    health/
      index.ts               # Health check endpoint
  services/
    users.service.ts         # Business logic — injected via decorators or DI
  schemas/
    shared.ts                # Reusable schema fragments (pagination, errors)
  types/
    index.d.ts               # Module augmentation for Fastify instance
.env.example                   # Required env vars template

Key Conventions

  • Everything is a plugin — routes, services, config, auth are all Fastify plugins registered via fastify.register().
  • @fastify/autoload to auto-register all plugins and route files by directory convention.
  • JSON Schema for validation and serialization — use TypeBox for type-safe schema authoring with automatic TypeScript inference.
  • Encapsulation — Fastify's plugin system encapsulates decorators and hooks. Use fastify-plugin (fp) only when a plugin must be visible to sibling and parent contexts.
  • Schema-first: Define request/response schemas. Fastify compiles them with fast-json-stringify for 2-3x faster serialization.
  • No any — leverage @fastify/type-provider-typebox for end-to-end type safety from schema to handler.

Essential Patterns

App Factory — app.ts

import Fastify from 'fastify';
import autoload from '@fastify/autoload';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));

export async function buildApp() {
  const app = Fastify({
    logger: {
      level: process.env.LOG_LEVEL ?? 'info',
    },
  }).withTypeProvider<TypeBoxTypeProvider>();

  // Register plugins (cors, helmet, swagger, env, etc.)
  await app.register(autoload, {
    dir: join(__dirname, 'plugins'),
    forceESM: true,
  });

  // Register routes
  await app.register(autoload, {
    dir: join(__dirname, 'routes'),
    options: { prefix: '/api' },
    forceESM: true,
  });

  return app;
}

Server Entry — server.ts

import { buildApp } from './app.js';

const app = await buildApp();

try {
  await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' });
} catch (err) {
  app.log.error(err);
  process.exit(1);
}

Typed Env Plugin — plugins/env.ts

import fp from 'fastify-plugin';
import fastifyEnv from '@fastify/env';

const schema = {
  type: 'object' as const,
  required: ['DATABASE_URL'],
  properties: {
    PORT: { type: 'number', default: 3000 },
    NODE_ENV: { type: 'string', default: 'development' },
    DATABASE_URL: { type: 'string' },
    JWT_SECRET: { type: 'string' },
  },
};

export default fp(async (fastify) => {
  await fastify.register(fastifyEnv, { schema, dotenv: true });
});

// Augment Fastify types — src/types/index.d.ts
// declare module 'fastify' {
//   interface FastifyInstance {
//     config: { PORT: number; NODE_ENV: string; DATABASE_URL: string; JWT_SECRET: string };
//   }
// }

Swagger Plugin — plugins/swagger.ts

import fp from 'fastify-plugin';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';

export default fp(async (fastify) => {
  await fastify.register(swagger, {
    openapi: {
      info: { title: 'API', version: '1.0.0' },
      components: {
        securitySchemes: {
          bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
        },
      },
    },
  });

  await fastify.register(swaggerUi, {
    routePrefix: '/docs',
  });
});

Sensible Plugin — plugins/sensible.ts

import fp from 'fastify-plugin';
import sensible from '@fastify/sensible';

export default fp(async (fastify) => {
  await fastify.register(sensible);
  // Provides: fastify.httpErrors.notFound(), .badRequest(), etc.
});

TypeBox Schemas — routes/users/schema.ts

import { Type, Static } from '@sinclair/typebox';

export const UserSchema = Type.Object({
  id: Type.String({ format: 'uuid' }),
  email: Type.String({ format: 'email' }),
  name: Type.String(),
  createdAt: Type.String({ format: 'date-time' }),
});

export const CreateUserSchema = Type.Object({
  email: Type.String({ format: 'email' }),
  password: Type.String({ minLength: 8 }),
  name: Type.String({ minLength: 1 }),
});

export const UserParamsSchema = Type.Object({
  id: Type.String({ format: 'uuid' }),
});

export type User = Static<typeof UserSchema>;
export type CreateUserInput = Static<typeof CreateUserSchema>;

Route Plugin — routes/users/index.ts

import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox';
import { CreateUserSchema, UserSchema, UserParamsSchema } from './schema.js';
import * as usersService from '../../services/users.service.js';

const usersRoutes: FastifyPluginAsyncTypebox = async (fastify) => {
  // POST /api/users
  fastify.post('/', {
    schema: {
      tags: ['users'],
      body: CreateUserSchema,
      response: { 201: UserSchema },
    },
  }, async (request, reply) => {
    const user = await usersService.create(request.body);
    return reply.status(201).send(user);
  });

  // GET /api/users/:id
  fastify.get('/:id', {
    schema: {
      tags: ['users'],
      params: UserParamsSchema,
      response: { 200: UserSchema },
    },
  }, async (request, reply) => {
    const user = await usersService.findById(request.params.id);
    if (!user) throw fastify.httpErrors.notFound(`User ${request.params.id} not found`);
    return reply.send(user);
  });
};

export default usersRoutes;

Service — services/users.service.ts

import { CreateUserInput, User } from '../routes/users/schema.js';

// Replace with real database client
const users = new Map<string, User>();

export async function create(input: CreateUserInput): Promise<User> {
  const user: User = {
    id: crypto.randomUUID(),
    email: input.email,
    name: input.name,
    createdAt: new Date().toISOString(),
  };
  users.set(user.id, user);
  return user;
}

export async function findById(id: string): Promise<User | undefined> {
  return users.get(id);
}

Hooks Lifecycle — plugins/auth.ts

import fp from 'fastify-plugin';
import { FastifyRequest } from 'fastify';

export default fp(async (fastify) => {
  // Decorate request with user property
  fastify.decorateRequest('user', null);

  // Reusable preHandler hook for protected routes
  fastify.decorate('authenticate', async (request: FastifyRequest) => {
    const authHeader = request.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
      throw fastify.httpErrors.unauthorized('Missing bearer token');
    }
    const token = authHeader.slice(7);

    try {
      // Replace with real JWT verification
      const payload = JSON.parse(atob(token.split('.')[1]));
      request.user = payload;
    } catch {
      throw fastify.httpErrors.unauthorized('Invalid token');
    }
  });
});

// Usage in a route:
// fastify.get('/me', { preHandler: [fastify.authenticate] }, async (request) => {
//   return request.user;
// });

Custom Error Handler

// In app.ts or as a plugin
app.setErrorHandler((error, request, reply) => {
  request.log.error(error);

  // Fastify validation errors
  if (error.validation) {
    return reply.status(400).send({
      error: 'Validation Error',
      message: error.message,
      details: error.validation,
    });
  }

  const statusCode = error.statusCode ?? 500;
  reply.status(statusCode).send({
    error: statusCode >= 500 ? 'Internal Server Error' : error.message,
    statusCode,
  });
});

Health Check — routes/health/index.ts

import { FastifyPluginAsync } from 'fastify';

const healthRoutes: FastifyPluginAsync = async (fastify) => {
  fastify.get('/', {
    schema: {
      tags: ['health'],
      response: {
        200: {
          type: 'object',
          properties: {
            status: { type: 'string' },
            uptime: { type: 'number' },
          },
        },
      },
    },
  }, async () => {
    return { status: 'ok', uptime: process.uptime() };
  });
};

export default healthRoutes;

First Steps After Scaffold

  1. Copy .env.example to .env and fill in values
  2. Install dependencies: pnpm install
  3. Start dev server: pnpm tsx watch src/server.ts
  4. Test the API: curl http://localhost:3000/api/health

Common Commands

# Development with hot reload
pnpm tsx watch src/server.ts

# Build
pnpm tsup src/server.ts --format esm --dts

# Production
node dist/server.js

# Type check
pnpm tsc --noEmit

# Test (vitest recommended)
pnpm vitest run

# Print registered routes
# (programmatic: call app.printRoutes() after app.ready())

# View generated Swagger
# Open http://localhost:3000/docs after starting the server

Integration Notes

  • Database: Pair with Prisma or Drizzle. Create a database plugin that decorates fastify.db with the client instance.
  • Auth: Use @fastify/jwt for built-in JWT sign/verify support. Decorate requests with the verified payload.
  • WebSockets: @fastify/websocket for native WebSocket support within the plugin system.
  • Rate Limiting: @fastify/rate-limit — configurable per-route or global.
  • File Uploads: @fastify/multipart for streaming or buffered file uploads.
  • Testing: Use app.inject() for in-process HTTP testing without starting a real server. This is Fastify's killer testing feature.
  • Serialization: Always define response schemas. Fastify uses fast-json-stringify to serialize 2-3x faster than JSON.stringify and also strips undeclared properties (security benefit).
  • Docker: Multi-stage build. Fastify's low overhead (~15k req/s on hello-world) makes it ideal for containerized microservices.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.