Fastify
Fast, low-overhead web framework for Node.js with TypeBox schema validation.
Additional References
-
references/plugins.md - Plugin architecture and dependency injection
-
references/typeid.md - Type-safe prefixed identifiers
Setup
npm i fastify @fastify/type-provider-typebox @sinclair/typebox
import Fastify from 'fastify' import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
const app = Fastify({ logger: true }).withTypeProvider<TypeBoxTypeProvider>()
Schema Definition
import { Type, Static } from '@sinclair/typebox'
// Request/response schemas with $id for OpenAPI export const UserSchema = Type.Object({ id: Type.String({ format: 'uuid' }), name: Type.String({ minLength: 1, maxLength: 100 }), email: Type.String({ format: 'email' }), createdAt: Type.String({ format: 'date-time' }), }, { $id: 'UserResponse' })
export type User = Static<typeof UserSchema>
// Input schemas (omit generated fields) export const CreateUserSchema = Type.Object({ name: Type.String({ minLength: 1, maxLength: 100 }), email: Type.String({ format: 'email' }), }, { $id: 'CreateUserRequest' })
export type CreateUserInput = Static<typeof CreateUserSchema>
Route with Full Schema
const TAGS = ['Users']
app.post('/users', { schema: { operationId: 'createUser', tags: TAGS, summary: 'Create a new user', description: 'Create a new user account', body: CreateUserSchema, response: { 201: UserSchema, 400: BadRequestErrorResponse, 401: UnauthorizedErrorResponse, 500: InternalServerErrorResponse, }, }, }, async (request, reply) => { const { name, email } = request.body // fully typed
const user = await createUser({ name, email }) return reply.status(201).send(user) })
Common Schema Patterns
// Path parameters const ParamsSchema = Type.Object({ id: Type.String({ format: 'uuid' }), })
// Query string with pagination const QuerySchema = Type.Object({ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 20 })), cursor: Type.Optional(Type.String()), sort: Type.Optional(Type.Union([Type.Literal('asc'), Type.Literal('desc')])), })
// Paginated response wrapper const PaginatedResponse = <T extends TSchema>(itemSchema: T) => Type.Object({ items: Type.Array(itemSchema), nextCursor: Type.Optional(Type.String()), hasMore: Type.Boolean(), })
app.get('/users/:id', { schema: { operationId: 'getUser', tags: ['Users'], summary: 'Get user by ID', params: ParamsSchema, querystring: QuerySchema, response: { 200: UserSchema, 400: BadRequestErrorResponse, 404: NotFoundErrorResponse, 500: InternalServerErrorResponse, }, }, }, async (request, reply) => { const { id } = request.params const { limit, cursor } = request.query // ... })
Modular Route Registration
// types.ts - Export typed Fastify instance import { FastifyInstance, FastifyBaseLogger, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, } from 'fastify' import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
export type FastifyTypebox = FastifyInstance< RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>, FastifyBaseLogger, TypeBoxTypeProvider
// routes/users.ts import { Type } from '@sinclair/typebox' import { FastifyTypebox } from '../types'
export async function userRoutes(app: FastifyTypebox) { app.get('/users', { schema: { response: { 200: Type.Array(UserSchema), }, }, }, async () => { return await listUsers() }) }
// index.ts import { userRoutes } from './routes/users'
app.register(userRoutes, { prefix: '/api/v1' })
Error Schemas (RFC 7807)
Use standardized error responses across all routes:
import { Type } from '@sinclair/typebox'
// Base ProblemDetail schema (RFC 7807) const ProblemDetail = Type.Object({ type: Type.String(), status: Type.Number(), title: Type.String(), detail: Type.String(), instance: Type.String(), traceId: Type.String(), })
// Specific error responses export const BadRequestErrorResponse = Type.Composite([ ProblemDetail, Type.Object({ type: Type.Literal('BAD_REQUEST'), status: Type.Literal(400), }), ], { $id: 'BadRequestErrorResponse' })
export const UnauthorizedErrorResponse = Type.Composite([ ProblemDetail, Type.Object({ type: Type.Literal('UNAUTHORIZED'), status: Type.Literal(401), }), ], { $id: 'UnauthorizedErrorResponse' })
export const ForbiddenErrorResponse = Type.Composite([ ProblemDetail, Type.Object({ type: Type.Literal('FORBIDDEN'), status: Type.Literal(403), }), ], { $id: 'ForbiddenErrorResponse' })
export const NotFoundErrorResponse = Type.Composite([ ProblemDetail, Type.Object({ type: Type.Literal('NOT_FOUND'), status: Type.Literal(404), }), ], { $id: 'NotFoundErrorResponse' })
export const InternalServerErrorResponse = Type.Composite([ ProblemDetail, Type.Object({ type: Type.Literal('INTERNAL_SERVER_ERROR'), status: Type.Literal(500), }), ], { $id: 'InternalServerErrorResponse' })
Error Handling
import { FastifyError, FastifyRequest, FastifyReply } from 'fastify'
// Custom error handler const globalErrorHandler = ( error: FastifyError, request: FastifyRequest, reply: FastifyReply ) => { // Handle Fastify validation errors if (error.code === 'FST_ERR_VALIDATION') { return reply.status(400).send({ type: 'BAD_REQUEST', status: 400, title: 'Validation Error', detail: error.message, instance: request.url, traceId: request.id, }) }
// Handle domain errors (if using error classes) if (error instanceof AppError) { return reply.status(error.status).send(error.toResponse()) }
// Default to internal server error request.log.error(error) return reply.status(500).send({ type: 'INTERNAL_SERVER_ERROR', status: 500, title: 'Internal Server Error', detail: 'Something went wrong', instance: request.url, traceId: request.id, }) }
app.setErrorHandler(globalErrorHandler)
Reusable Schemas (Shared References)
// Add schema to instance for $ref usage app.addSchema({ $id: 'User', ...UserSchema, })
app.addSchema({ $id: 'Error', ...ErrorSchema, })
// Reference in routes app.get('/me', { schema: { response: { 200: Type.Ref('User'), 401: Type.Ref('Error'), }, }, }, handler)
Headers and Auth
const AuthHeadersSchema = Type.Object({ authorization: Type.String({ pattern: '^Bearer .+$' }), })
app.get('/protected', { schema: { headers: AuthHeadersSchema, response: { 200: UserSchema, 401: UnauthorizedErrorResponse, }, }, preValidation: async (request, reply) => { const token = request.headers.authorization?.replace('Bearer ', '') if (!token || !verifyToken(token)) { throw new UnauthorizedError('Invalid or missing token') } }, }, handler)
Auth & Permissions
Role-based permission checks with decorators:
import type { FastifyRequest, FastifyReply } from 'fastify'
// Permission constants const Permissions = [ 'user:read', 'user:write', 'user:delete', 'admin:access', ] as const
type Permission = typeof Permissions[number]
// Role-based permission sets const RolePermissions = { admin: new Set<Permission>(['user:read', 'user:write', 'user:delete', 'admin:access']), user: new Set<Permission>(['user:read', 'user:write']), readonly: new Set<Permission>(['user:read']), } as const
// Extend FastifyRequest with token data declare module 'fastify' { interface FastifyRequest { token: { userId: string role: keyof typeof RolePermissions permissions: Permission[] } } }
// Permission check decorator app.decorate('hasPermissions', (requiredPermissions: Permission[]) => { return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => { const userPermissions = request.token.permissions
for (const permission of requiredPermissions) {
if (!userPermissions.includes(permission)) {
throw new ForbiddenError(`Missing permission: ${permission}`)
}
}
} })
// Usage in routes app.delete('/users/:id', { schema: { operationId: 'deleteUser', tags: ['Users'], params: Type.Object({ id: Type.String() }), response: { 204: Type.Null(), 401: UnauthorizedErrorResponse, 403: ForbiddenErrorResponse, 404: NotFoundErrorResponse, }, }, preHandler: app.hasPermissions(['user:delete']), }, async (request, reply) => { await deleteUser(request.params.id) return reply.status(204).send() })
Guidelines
-
Always define schemas with Type.Object({ ... })
-
full JSON Schema required in Fastify v5
-
Add $id to all schemas for OpenAPI generation and reusability
-
Add operationId , tags , and summary to all routes for documentation
-
Define response schemas for ALL status codes (200, 400, 401, 403, 404, 500)
-
Use RFC 7807 ProblemDetail format for errors with Type.Composite
-
Use Static<typeof Schema> to derive TypeScript types from schemas
-
Split input schemas (CreateX) from output schemas (X) - omit generated fields
-
Use Type.Optional() for optional fields, not ? in the type
-
Export FastifyTypebox type for modular route files
-
Add format validators: uuid , email , date-time , uri
-
Use Type.Union([Type.Literal(...)]) for string enums
-
Use Fastify plugins with fp() for dependency injection - see references/plugins.md
-
Use preHandler with hasPermissions() decorator for protected routes
-
Use TypeID for type-safe prefixed identifiers - see references/typeid.md