Scaffold a production-ready Fastify 5.x API with TypeScript, plugin architecture, JSON Schema validation, and auto-generated Swagger docs.
79
71%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./backend-node/fastify-project-starter/SKILL.mdScaffold a production-ready Fastify 5.x API with TypeScript, plugin architecture, JSON Schema validation, and auto-generated Swagger docs.
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}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 templatefastify.register().@fastify/autoload to auto-register all plugins and route files by directory convention.fastify-plugin (fp) only when a plugin must be visible to sibling and parent contexts.fast-json-stringify for 2-3x faster serialization.any — leverage @fastify/type-provider-typebox for end-to-end type safety from schema to handler.app.tsimport 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.tsimport { 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);
}plugins/env.tsimport 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 };
// }
// }plugins/swagger.tsimport 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',
});
});plugins/sensible.tsimport fp from 'fastify-plugin';
import sensible from '@fastify/sensible';
export default fp(async (fastify) => {
await fastify.register(sensible);
// Provides: fastify.httpErrors.notFound(), .badRequest(), etc.
});routes/users/schema.tsimport { 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>;routes/users/index.tsimport { 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;services/users.service.tsimport { 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);
}plugins/auth.tsimport 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;
// });// 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,
});
});routes/health/index.tsimport { 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;.env.example to .env and fill in valuespnpm installpnpm tsx watch src/server.tscurl http://localhost:3000/api/health# 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 serverfastify.db with the client instance.@fastify/jwt for built-in JWT sign/verify support. Decorate requests with the verified payload.@fastify/websocket for native WebSocket support within the plugin system.@fastify/rate-limit — configurable per-route or global.@fastify/multipart for streaming or buffered file uploads.app.inject() for in-process HTTP testing without starting a real server. This is Fastify's killer testing feature.response schemas. Fastify uses fast-json-stringify to serialize 2-3x faster than JSON.stringify and also strips undeclared properties (security benefit).181fcbc
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.