Claude Code with Fastify: Plugins, Schemas, Hooks, and Validation
Why Fastify developers need a different Claude Code setup
Claude Code understands Fastify. It knows fastify.register(), preHandler, JSON Schema validation, reply serialization, and the plugin architecture. What it does not know is your project: which plugins are encapsulated versus globally decorated, whether you use raw JSON Schema or TypeBox, which hooks run at the route level versus the plugin level, and how you structure error handling.
The gap between "Claude knows Fastify" and "Claude writes Fastify code that fits your codebase" shows up in specific, painful ways. Claude generates routes with req.body accessed without a schema, losing Fastify's serialization speedup entirely. It registers plugins with fastify.register() but forgets that decorated values are encapsulated, then tries to access a plugin's decorator from a sibling plugin. It adds preHandler hooks inline on routes when the pattern should be a shared plugin hook. It uses raw JSON Schema when your codebase has standardized on TypeBox.
None of these are fundamental Claude Code limitations. They are configuration gaps. The Claude Code setup guide explains the initial install and auth flow. This guide covers everything Fastify-specific that goes into CLAUDE.md to close those gaps permanently.
The Fastify CLAUDE.md
The CLAUDE.md at your project root is the single most effective tool for improving Claude Code output on Fastify projects. For Fastify, it needs to answer six questions: how is the project structured, which schema library is in use, how do plugins relate to each other, how are hooks organized, how are errors handled, and what are the hard rules?
# Fastify project rules
## Project structure
- Entry point: `src/app.ts` (Fastify instance creation, plugin registration, export)
- Server entry: `src/server.ts` (listen(), port/host from env)
- Routes: `src/routes/{resource}/index.ts` (one directory per resource)
- Each route directory exports a Fastify plugin with `fastify-plugin` wrapper
- Plugins: `src/plugins/` (shared plugins: db, auth, error-handler)
- `src/plugins/db.ts` (Prisma client, decorated onto fastify as `fastify.db`)
- `src/plugins/auth.ts` (JWT verification, `fastify.authenticate` decorator)
- `src/plugins/error-handler.ts` (global setErrorHandler)
- Schemas: `src/schemas/{resource}.ts` (TypeBox schema definitions, exported for reuse)
- Types: `src/types/` (shared TypeScript interfaces and Fastify type augmentation)
## Runtime and dependencies
- Node.js: 20.x LTS
- Fastify: 4.x
- TypeScript: 5.x, strict mode
- Schema library: @sinclair/typebox (NOT raw JSON Schema objects)
- ORM: Prisma 5.x (client decorated as fastify.db)
- Auth: @fastify/jwt
- Validation: Fastify built-in (Ajv v8 under the hood via TypeBox)
- Testing: tap or Vitest with @fastify/one-page (see Testing section)
## Plugin encapsulation rules
- Plugins registered with `fastify.register()` are encapsulated by default
- To share a plugin's decorators across the entire app, wrap the plugin with `fastify-plugin`:
import fp from 'fastify-plugin'
export default fp(async function dbPlugin(fastify) { ... })
- Route plugins are always encapsulated (do NOT use fastify-plugin on route files)
- Shared plugins (db, auth, error-handler) always use fastify-plugin
- Never assume a decorator registered in one plugin is available in a sibling plugin unless it uses fastify-plugin
## Schema library: TypeBox only
- Use @sinclair/typebox for all route schemas. Never write raw JSON Schema objects.
- Import pattern: import { Type, Static } from '@sinclair/typebox'
- Define schemas in src/schemas/, export the Type and the Static type:
export const CreateUserSchema = Type.Object({
name: Type.String({ minLength: 1 }),
email: Type.String({ format: 'email' }),
age: Type.Optional(Type.Integer({ minimum: 0 }))
})
export type CreateUserBody = Static<typeof CreateUserSchema>
- Attach schema to routes via the schema option:
{ schema: { body: CreateUserSchema, response: { 201: UserResponseSchema } } }
- Always define response schemas for 200/201 (this enables Fastify's serialization speedup)
- Use Type.Strict() for response schemas to strip unknown fields
## Hook rules
- Application-level hooks (onRequest, onSend, onClose): register in src/app.ts or shared plugins
- Route-level hooks (preHandler, preValidation): register in route plugin scope or inline on route
- Authentication: always via preHandler hook using fastify.authenticate, not inline in handler
- Do not add hooks to the root fastify instance from within route plugins (they are encapsulated)
- Hook order: onRequest -> preParsing -> preValidation -> preHandler -> handler -> onSend -> onResponse
## Error handling
- Global error handler registered in src/plugins/error-handler.ts (fastify-plugin wrapped)
- Use fastify.httpErrors (from @fastify/sensible) for all error creation:
throw fastify.httpErrors.notFound('User not found')
throw fastify.httpErrors.badRequest('Invalid input')
- Never throw raw Error objects from route handlers
- Prisma not-found pattern: catch PrismaClientKnownRequestError with code P2025, convert to 404
## Running the project
- Dev: `npx ts-node src/server.ts` or `npm run dev` (tsx watch)
- Build: `npm run build` (tsc)
- Start: `node dist/server.js`
## Tests
- Framework: tap or Vitest
- Pattern: build the Fastify app, inject requests, assert on status + body
- Run: `npm test`
- Fast pass: `npm run test:routes` for route-level tests only
## Hard rules
- Every route has a schema defined. No unschematized routes.
- TypeBox for all schemas. No raw JSON Schema objects.
- Response schemas for all success status codes.
- Route plugins never use fastify-plugin. Shared plugins always use fastify-plugin.
- Authentication via preHandler hook only. Never inline JWT verification in handlers.
- No console.log in production code. Use fastify.log (pino under the hood).
- Always define TypeScript types from TypeBox Static<> for request body/params/query.
Three sections in this CLAUDE.md prevent the highest-frequency Claude Code failures with Fastify.
The plugin encapsulation rules section is critical. Fastify's encapsulation model is its most distinctive architectural feature and the one Claude gets wrong most consistently. Without explicit guidance, Claude will access fastify.db from a route plugin before realizing the db plugin needs fastify-plugin wrapping to escape the encapsulation boundary. The rule is simple: shared plugins use fastify-plugin, route plugins never do. Making this explicit once prevents the error across every file Claude generates.
The TypeBox only declaration matters because Claude knows both raw JSON Schema and TypeBox, and will pick based on recent context. Raw JSON Schema works in Fastify, but it splits your types from your runtime validation. TypeBox gives you a single CreateUserSchema definition that serves as both the Ajv validator and the TypeScript type via Static<>. One line in CLAUDE.md locks Claude to the right pattern.
The response schema rule is the performance lever that most developers miss. Fastify's serialization layer (using fast-json-stringify) only activates when a response schema is defined. Without response schemas, Fastify falls back to JSON.stringify. Defining response schemas for every success code can increase serialization throughput by 100 to 400 percent depending on payload size. The CLAUDE.md rule ensures Claude always generates both input and output schemas.
Plugin encapsulation and the fastify-plugin pattern
Fastify's encapsulation model is worth understanding deeply before configuring Claude Code around it, because it affects every architectural decision in a Fastify app.
When you call fastify.register(myPlugin), Fastify creates a child context. Any decorator, hook, or route registered inside myPlugin is scoped to that context and its descendants. A sibling plugin cannot see those decorators. A parent can.
fastify-plugin breaks the encapsulation boundary. When you wrap a plugin with fp(), Fastify applies its effects directly to the parent context instead of a child. This is how shared plugins like a database connection or JWT authentication become available everywhere.
The failure mode without this in CLAUDE.md: Claude generates a route handler that calls fastify.db.user.findMany(), but the db plugin was not wrapped with fastify-plugin. The handler gets fastify.db is not a function at runtime. The error is confusing because the db plugin is registered before the route plugin in app.ts.
Add to CLAUDE.md with a concrete example:
## Plugin structure examples
### Shared plugin (always uses fastify-plugin)
// src/plugins/db.ts
import fp from 'fastify-plugin'
import { PrismaClient } from '@prisma/client'
declare module 'fastify' {
interface FastifyInstance {
db: PrismaClient
}
}
export default fp(async function dbPlugin(fastify) {
const prisma = new PrismaClient()
fastify.decorate('db', prisma)
fastify.addHook('onClose', async () => {
await prisma.$disconnect()
})
}, { name: 'db' })
### Route plugin (never uses fastify-plugin)
// src/routes/users/index.ts
import { FastifyPluginAsync } from 'fastify'
import { CreateUserSchema, CreateUserBody, UserResponseSchema } from '../../schemas/user.js'
const usersRoutes: FastifyPluginAsync = async (fastify) => {
fastify.post<{ Body: CreateUserBody }>('/users', {
schema: {
body: CreateUserSchema,
response: { 201: UserResponseSchema }
},
preHandler: [fastify.authenticate]
}, async (request, reply) => {
const user = await fastify.db.user.create({ data: request.body })
return reply.status(201).send(user)
})
}
export default usersRoutes
With these examples in CLAUDE.md, Claude generates the correct plugin structure without being reminded per task. The TypeScript module augmentation for FastifyInstance is also essential: without it, fastify.db and fastify.authenticate are TypeScript errors, and Claude will either suppress the error or generate type assertions.
TypeBox schema validation and serialization
TypeBox is Fastify's recommended TypeScript schema solution and the one Claude Code should always use in a Fastify project. The reason to prefer it over raw JSON Schema is that it keeps your runtime validation and TypeScript types in sync from a single source.
Define schemas in src/schemas/ and export both the TypeBox type and the TypeScript type:
// src/schemas/user.ts
import { Type, Static } from '@sinclair/typebox'
export const CreateUserSchema = Type.Object({
name: Type.String({ minLength: 1, maxLength: 100 }),
email: Type.String({ format: 'email' }),
role: Type.Union([
Type.Literal('admin'),
Type.Literal('user'),
Type.Literal('viewer')
])
}, { $id: 'CreateUser', additionalProperties: false })
export type CreateUserBody = Static<typeof CreateUserSchema>
export const UserResponseSchema = Type.Object({
id: Type.String({ format: 'uuid' }),
name: Type.String(),
email: Type.String(),
role: Type.String(),
createdAt: Type.String({ format: 'date-time' })
}, { $id: 'UserResponse' })
export type UserResponse = Static<typeof UserResponseSchema>
// Shared schemas for reuse across routes via addSchema
export const PaginationSchema = Type.Object({
page: Type.Integer({ minimum: 1, default: 1 }),
limit: Type.Integer({ minimum: 1, maximum: 100, default: 20 })
})
export type PaginationQuery = Static<typeof PaginationSchema>
Add to CLAUDE.md:
## TypeBox schema conventions
### additionalProperties: false on all input schemas
### $id on every schema for shared schema reuse
### Use Type.Strict() on response schemas to strip unknown fields before serialization
### Register shared schemas globally in app.ts:
fastify.addSchema(CreateUserSchema)
fastify.addSchema(UserResponseSchema)
### Reference registered schemas from route schemas:
{ schema: { body: { $ref: 'CreateUser#' } } }
### Error response schema for consistent error shapes:
export const ErrorResponseSchema = Type.Object({
statusCode: Type.Integer(),
error: Type.String(),
message: Type.String()
})
The $id field enables Fastify's addSchema + $ref pattern for shared schemas across routes. Without $id, you cannot use $ref, and you will import the full TypeBox object into every route file instead. Claude handles the $ref pattern correctly when the schema IDs are established in CLAUDE.md.
The performance implication of response schemas is worth restating. Fastify uses fast-json-stringify for serialization when a response schema is present, which is significantly faster than JSON.stringify. It also strips properties not defined in the schema, which prevents accidentally leaking internal fields like password hashes or internal IDs in API responses.
For projects that use Prisma with Fastify, the Claude Code Prisma guide covers the specific patterns for typed queries, migration safety, and generated types that pair naturally with TypeBox response schemas.
Fastify hook lifecycle
Fastify's hook system is more structured than Express middleware. Hooks have a defined execution order, clear scope rules, and specific async behavior. Claude Code generates correct hook usage when the order and scope are explicit in CLAUDE.md.
The full hook order for a request:
onRequest- first hook, runs before body parsingpreParsing- runs before body parsingpreValidation- runs before schema validationpreHandler- runs after validation, before handler- handler
onSend- runs before reply is sent, can modify payloadonResponse- runs after reply is sent (async, does not block response)
Add to CLAUDE.md:
## Hook patterns
### Authentication hook (preHandler, runs after validation)
// In auth plugin (fastify-plugin wrapped):
fastify.decorate('authenticate', async function(request, reply) {
try {
await request.jwtVerify()
} catch (err) {
reply.send(err)
}
})
// On protected routes:
fastify.get('/profile', {
preHandler: [fastify.authenticate]
}, handler)
### Rate limiting (onRequest, runs before everything)
// Register globally in app.ts:
fastify.addHook('onRequest', async (request, reply) => {
// Check rate limit headers / counters here
})
### Request logging (onResponse, does not block the response)
fastify.addHook('onResponse', async (request, reply) => {
fastify.log.info({
method: request.method,
url: request.url,
statusCode: reply.statusCode,
responseTime: reply.elapsedTime
})
})
### Payload transformation (onSend, can modify the serialized payload)
fastify.addHook('onSend', async (request, reply, payload) => {
// payload is the serialized string at this point
return payload
})
### Hook scope rule:
- Hooks registered in a plugin scope apply only to that scope
- Hooks registered in the root app.ts apply globally
- Use fastify-plugin wrapping for shared plugins whose hooks should apply globally
The most common hook mistake Claude makes without this guidance: adding a global preHandler authentication hook inside a route plugin. Because route plugins are encapsulated, that hook only applies to routes in that plugin, not the entire app. The developer expects global auth enforcement and gets selective enforcement with no error. The CLAUDE.md rule prevents this.
For a detailed look at how hooks work across Claude Code projects generally, including permission hooks and lifecycle events at the Claude Code tool level, the Claude Code hooks guide covers the full system.
Error handling and the setErrorHandler pattern
Fastify's centralized error handling is superior to route-level try/catch when configured correctly. Claude Code defaults to per-route try/catch blocks, which leads to inconsistent error response shapes across your API. A single setErrorHandler in a shared plugin fixes this.
Add to CLAUDE.md:
## Error handling patterns
### Global error handler (src/plugins/error-handler.ts)
import fp from 'fastify-plugin'
import { FastifyError } from 'fastify'
export default fp(async function errorHandler(fastify) {
fastify.setErrorHandler(function(error: FastifyError, request, reply) {
const statusCode = error.statusCode ?? 500
// Log 5xx errors, skip 4xx noise
if (statusCode >= 500) {
fastify.log.error({ err: error }, 'Unhandled error')
}
reply.status(statusCode).send({
statusCode,
error: error.name ?? 'Error',
message: error.message
})
})
})
### Prisma error mapping
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
// In route handler or service:
try {
const user = await fastify.db.user.findUniqueOrThrow({ where: { id } })
return user
} catch (err) {
if (err instanceof PrismaClientKnownRequestError && err.code === 'P2025') {
throw fastify.httpErrors.notFound(`User ${id} not found`)
}
throw err
}
### Use @fastify/sensible for typed HTTP errors:
import sensible from '@fastify/sensible'
fastify.register(sensible)
// Then in any handler:
throw fastify.httpErrors.unauthorized('Token expired')
throw fastify.httpErrors.forbidden('Insufficient permissions')
throw fastify.httpErrors.conflict('Email already registered')
With this in CLAUDE.md, Claude generates route handlers that throw typed httpErrors rather than new Error() or raw status codes. The global error handler catches them, shapes the response consistently, and logs the right level. The Prisma error mapping is worth making explicit because P2025 is the most common Prisma error in CRUD routes, and the conversion to a 404 is easy to forget in the catch block.
Fastify with Prisma: decorated client pattern
Fastify and Prisma pair well through the decorator pattern. Decorating fastify.db in a shared plugin gives every route access to the typed Prisma client without import cycles or global module state.
Add to CLAUDE.md:
## Prisma integration
### Client decoration (src/plugins/db.ts)
import fp from 'fastify-plugin'
import { PrismaClient } from '@prisma/client'
declare module 'fastify' {
interface FastifyInstance {
db: PrismaClient
}
}
export default fp(async function db(fastify) {
const prisma = new PrismaClient({
log: fastify.log.level === 'debug'
? ['query', 'info', 'warn', 'error']
: ['warn', 'error']
})
await prisma.$connect()
fastify.decorate('db', prisma)
fastify.addHook('onClose', async () => {
await prisma.$disconnect()
})
}, { name: 'db' })
### Route usage pattern
fastify.get<{ Params: { id: string } }>('/users/:id', {
schema: {
params: Type.Object({ id: Type.String({ format: 'uuid' }) }),
response: { 200: UserResponseSchema }
},
preHandler: [fastify.authenticate]
}, async (request, reply) => {
try {
return await fastify.db.user.findUniqueOrThrow({
where: { id: request.params.id },
select: { id: true, name: true, email: true, role: true, createdAt: true }
})
} catch (err) {
if (err instanceof PrismaClientKnownRequestError && err.code === 'P2025') {
throw fastify.httpErrors.notFound(`User ${request.params.id} not found`)
}
throw err
}
})
### Migration rules
- Inspect only: fastify.db.$queryRaw or prisma db pull (read-only)
- Never run: prisma migrate deploy, prisma db push (require explicit permission)
- Migration files: never auto-edit files in prisma/migrations/
The select clause pattern is worth making explicit in CLAUDE.md. Without it, Claude will return full Prisma model objects from endpoints, which includes fields not in your TypeBox response schema. Fastify's serialization will strip them (if using Type.Strict()), but returning unnecessary data from the database wastes query time. The select clause keeps the database query scope tight.
Testing Fastify routes with injection
Fastify's built-in inject method makes route testing straightforward without starting a real HTTP server. Claude Code generates correct injection tests when the pattern is in CLAUDE.md.
Add to CLAUDE.md:
## Testing patterns
### App factory for tests
// src/app.ts, exports a build function, not a started server
import Fastify from 'fastify'
import dbPlugin from './plugins/db.js'
import authPlugin from './plugins/auth.js'
import errorHandler from './plugins/error-handler.js'
import usersRoutes from './routes/users/index.js'
export async function buildApp(opts = {}) {
const fastify = Fastify({ logger: false, ...opts })
await fastify.register(dbPlugin)
await fastify.register(authPlugin)
await fastify.register(errorHandler)
await fastify.register(usersRoutes, { prefix: '/api' })
return fastify
}
### Route test pattern (Vitest)
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { buildApp } from '../../app.js'
import type { FastifyInstance } from 'fastify'
describe('GET /api/users/:id', () => {
let app: FastifyInstance
beforeAll(async () => {
app = await buildApp()
await app.ready()
})
afterAll(async () => {
await app.close()
})
it('returns 200 with user data', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users/valid-uuid-here',
headers: { authorization: 'Bearer valid-test-token' }
})
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body).toHaveProperty('id')
expect(body).not.toHaveProperty('passwordHash')
})
it('returns 404 for unknown user', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users/00000000-0000-0000-0000-000000000000',
headers: { authorization: 'Bearer valid-test-token' }
})
expect(response.statusCode).toBe(404)
})
})
### After every code change
1. Run `npm test` for full suite
2. Check that new routes have schemas (Claude Code test generation validates this)
3. Check that response objects do not leak fields not in the response schema
The app.inject() approach is Fastify's recommended test method. It bypasses the TCP layer entirely, making tests fast and parallel-safe. Claude Code generates these correctly once the buildApp factory pattern is established in CLAUDE.md. Without the factory, Claude will try to import the started server, which requires a running process and makes tests non-deterministic.
The expect(body).not.toHaveProperty('passwordHash') assertion pattern is worth adding to CLAUDE.md as a convention. It verifies that response schema serialization is actually stripping sensitive fields, not just that the route returns the right shape.
For broader Claude Code testing patterns including coverage analysis and TDD loops, the Claude Code testing guide covers the general workflow that applies across Node.js projects.
Claude Code permission hooks for Fastify
Fastify projects typically include Prisma CLI commands for migrations and the Fastify CLI for project scaffolding. Gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(npm run dev*)",
"Bash(npm test*)",
"Bash(npm run build*)",
"Bash(npx prisma generate*)",
"Bash(npx prisma migrate status*)",
"Bash(npx prisma db pull*)",
"Bash(npx prisma migrate diff*)"
],
"deny": [
"Bash(npx prisma migrate deploy*)",
"Bash(npx prisma migrate reset*)",
"Bash(npx prisma db push*)",
"Bash(npx prisma db seed*)"
]
}
}
This setup lets Claude generate Prisma client types (prisma generate), inspect the current migration state, and diff schema changes, but blocks it from applying migrations to any database or resetting the database state. For a Fastify API in active development, the right boundary is: Claude can read the migration state, you control applying it.
For a complete walkthrough of Claude Code permission rules across project types, the Claude Code hooks guide covers the full settings.local.json system.
What Fastify developers get wrong first
Three patterns come up consistently when Fastify developers start using claude code fastify workflows in production projects.
Not specifying TypeBox versus raw JSON Schema. Claude will use whichever it has seen most recently. If you started your project with a few raw JSON Schema route definitions before adding TypeBox, Claude will alternate between them. The result is mixed validation styles that are harder to maintain and lose the TypeScript type inference from Static<>. One line in CLAUDE.md eliminates this: "Schema library: @sinclair/typebox. Never write raw JSON Schema objects."
Registering shared plugins without fastify-plugin. The error is cryptic (fastify.db is not a function rather than "plugin not registered"), and debugging it requires understanding Fastify's encapsulation model. Claude generates this mistake frequently without explicit guidance because fastify.register() with and without fastify-plugin looks identical at the call site. The CLAUDE.md rule plus the typed module augmentation pattern (declare module 'fastify') gives Claude enough signal to generate it correctly every time.
Missing response schemas. Claude generates input schemas reliably because Fastify throws a validation error when input is invalid and input without a schema passes everything through. Response schemas fail silently, which means Claude skips them when they are not in CLAUDE.md. Without response schemas, Fastify uses JSON.stringify instead of fast-json-stringify, and sensitive fields are not automatically stripped. Two words in the hard rules section prevent this: "Response schemas for all success status codes."
Getting more from your Fastify workflow
The CLAUDE.md configuration in this guide produces a Fastify setup where plugin encapsulation is always correct, TypeBox schemas cover both validation and serialization, hooks run at the right scope, and tests use inject() for fast, deterministic results.
The same principle applies here as to any Claude Code best practices workflow: Claude Code output quality tracks directly with the quality of your CLAUDE.md. A Fastify project without CLAUDE.md gets generic Express-influenced Node.js patterns. A project with the configuration above gets plugin registration, schema validation, and hook placement that matches Fastify's actual architecture.
Start with the CLAUDE.md template above, get the plugin encapsulation and TypeBox sections right first, then add the hook lifecycle rules and Prisma error mapping. The TypeScript type augmentation for FastifyInstance is worth adding early, because it gives Claude's type inference enough signal to catch decorator access errors before they reach runtime. Claudify includes a Fastify-specific CLAUDE.md template as part of the Claude Code workflow kit, pre-configured for TypeBox schemas, Prisma decoration, @fastify/jwt authentication, and Vitest injection tests.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify