Web Development · Backend
Fastify in 2026: The Node.js API Framework That Stayed When Everyone Left
While the JavaScript ecosystem chased Bun, Deno, and edge runtimes, Fastify quietly became the production choice for high-throughput Node.js APIs. Here's why it's still the right call for many teams.
Anurag Verma
8 min read
Sponsored
The JavaScript backend ecosystem has fragmented a lot in the last two years. Bun ships its own web framework. Deno has its own standard library. Edge runtimes run Workers, not Node.js. Meanwhile, the question of “what do I use for a production Node.js API?” has a quieter, less hyped answer: Fastify.
Fastify isn’t new. It launched in 2016. But it’s been compounding improvements methodically, and in 2026 it handles a genuinely compelling combination: performance close to the theoretical Node.js ceiling, a schema-based validation system that generates OpenAPI docs automatically, a plugin architecture that scales from a single file to a hundred-module system, and TypeScript support that actually works without ceremony.
This isn’t an argument that Fastify beats Hono or Bun’s built-in server on benchmarks. For edge deployments or maximum raw throughput, those tools have their place. This is about building a maintainable, well-documented API that runs on Node.js and needs to serve production traffic for years.
The Basics
Install and the simplest possible server:
import Fastify from 'fastify'
const fastify = Fastify({ logger: true })
fastify.get('/health', async () => {
return { status: 'ok', time: new Date().toISOString() }
})
await fastify.listen({ port: 3000, host: '0.0.0.0' })
That’s it. No separate listen callback, no req/res ceremony. Routes return values or promises, and Fastify serializes them.
Schema Validation
The biggest practical difference from Express is schema-first route definitions. You define the shape of request and response, and Fastify validates incoming requests and serializes responses using fast-json-stringify (which pre-compiles JSON serialization for each schema, typically 2-5x faster than JSON.stringify).
import Fastify, { FastifyRequest, FastifyReply } from 'fastify'
const fastify = Fastify()
// Define schema shapes inline using JSON Schema
const createUserSchema = {
body: {
type: 'object',
required: ['email', 'name'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 1, maxLength: 100 },
role: { type: 'string', enum: ['admin', 'member', 'viewer'], default: 'member' },
},
additionalProperties: false,
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string' },
name: { type: 'string' },
role: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' },
},
},
},
} as const
fastify.post(
'/users',
{ schema: createUserSchema },
async (req: FastifyRequest<{ Body: { email: string; name: string; role?: string } }>, reply: FastifyReply) => {
const { email, name, role = 'member' } = req.body
// req.body is already validated — email is a valid email, name is within length limits
const user = await db.users.create({ email, name, role })
reply.status(201)
return user
}
)
Invalid requests (missing required fields, wrong types, values outside enum) get a 400 response with a structured error automatically. No validation middleware to write.
The response schema does double duty: it strips any fields not listed in the schema (preventing accidental data leaks) and enables fast serialization.
TypeBox for TypeScript Integration
Writing JSON Schema by hand gets tedious in TypeScript. TypeBox generates JSON Schema from TypeScript types:
import { Type, Static } from '@sinclair/typebox'
import Fastify, { FastifyRequest } from 'fastify'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
const fastify = Fastify().withTypeProvider<TypeBoxTypeProvider>()
const CreateProductBody = Type.Object({
name: Type.String({ minLength: 1, maxLength: 200 }),
price: Type.Number({ minimum: 0 }),
sku: Type.String({ pattern: '^[A-Z]{3}-\\d{4}$' }),
categoryId: Type.Optional(Type.String({ format: 'uuid' })),
})
const ProductResponse = Type.Object({
id: Type.String({ format: 'uuid' }),
name: Type.String(),
price: Type.Number(),
sku: Type.String(),
createdAt: Type.String({ format: 'date-time' }),
})
fastify.post(
'/products',
{
schema: {
body: CreateProductBody,
response: { 201: ProductResponse },
},
},
async (req) => {
// req.body is typed as Static<typeof CreateProductBody>
// TypeScript knows the shape without extra type assertions
const { name, price, sku, categoryId } = req.body
const product = await db.products.create({ name, price, sku, categoryId })
return product
}
)
With TypeBoxTypeProvider, TypeScript infers request body types directly from your schema. No separate interface declarations, no casting.
The Plugin System
Fastify’s plugin system is where it scales beyond a single file. Plugins are functions that extend the Fastify instance with routes, decorators, and hooks, with scope isolation.
A typical structure for a medium-sized API:
src/
server.ts — creates and configures the Fastify instance
plugins/
db.ts — database connection, registers env.db on fastify instance
auth.ts — registers preHandler hook for JWT verification
swagger.ts — registers @fastify/swagger for OpenAPI docs
routes/
users/
index.ts — registers user routes
schema.ts — TypeBox schemas for user routes
products/
index.ts
schema.ts
// src/plugins/db.ts
import fp from 'fastify-plugin'
import { drizzle } from 'drizzle-orm/node-postgres'
declare module 'fastify' {
interface FastifyInstance {
db: ReturnType<typeof drizzle>
}
}
export default fp(async (fastify) => {
const db = drizzle(process.env.DATABASE_URL!)
fastify.decorate('db', db)
})
// src/routes/users/index.ts
import { FastifyInstance } from 'fastify'
import { CreateUserBody, UserResponse } from './schema'
export default async function userRoutes(fastify: FastifyInstance) {
fastify.post('/', {
schema: { body: CreateUserBody, response: { 201: UserResponse } },
}, async (req) => {
return fastify.db.insert(users).values(req.body).returning()
})
fastify.get('/:id', {
schema: {
params: Type.Object({ id: Type.String({ format: 'uuid' }) }),
response: { 200: UserResponse },
},
}, async (req) => {
return fastify.db.query.users.findFirst({
where: eq(users.id, req.params.id)
})
})
}
// src/server.ts
import Fastify from 'fastify'
import dbPlugin from './plugins/db'
import authPlugin from './plugins/auth'
import userRoutes from './routes/users'
import productRoutes from './routes/products'
const fastify = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } })
await fastify.register(dbPlugin)
await fastify.register(authPlugin)
// Route prefix scoping
await fastify.register(userRoutes, { prefix: '/users' })
await fastify.register(productRoutes, { prefix: '/products' })
export default fastify
The fastify-plugin wrapper (fp) breaks scope isolation deliberately. It makes the database decorator available to all routes. Routes registered with fastify.register() without fp get an isolated scope: decorators registered inside that scope don’t leak out.
Auto-Generated OpenAPI Docs
Add @fastify/swagger and @fastify/swagger-ui:
import swagger from '@fastify/swagger'
import swaggerUI from '@fastify/swagger-ui'
await fastify.register(swagger, {
openapi: {
info: { title: 'My API', version: '1.0.0' },
components: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
},
},
},
})
await fastify.register(swaggerUI, {
routePrefix: '/docs',
uiConfig: { deepLinking: true },
})
Every route with a schema definition shows up in /docs automatically. The OpenAPI spec is generated at runtime from the same TypeBox schemas that validate your requests. No separate spec file to maintain.
Hooks
Fastify’s hook system gives you lifecycle points without middleware stacks:
// Run before every route handler — authentication check
fastify.addHook('preHandler', async (request, reply) => {
const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) {
reply.status(401).send({ error: 'Missing token' })
return
}
try {
request.user = await verifyToken(auth.slice(7))
} catch {
reply.status(401).send({ error: 'Invalid token' })
}
})
// Run after response is sent — logging, cleanup
fastify.addHook('onSend', async (request, reply, payload) => {
fastify.log.info({
method: request.method,
url: request.url,
statusCode: reply.statusCode,
responseTime: reply.elapsedTime,
})
return payload // Must return payload
})
The hook chain is explicit and ordered. You know exactly when each hook runs relative to route handlers.
Error Handling
Fastify’s error model separates framework errors from application errors cleanly:
// Custom error class
class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message)
}
}
// Global error handler
fastify.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
reply.status(error.statusCode).send({
error: error.message,
code: error.code,
})
return
}
// Fastify validation errors
if (error.validation) {
reply.status(400).send({
error: 'Validation failed',
details: error.validation,
})
return
}
// Unexpected errors
fastify.log.error(error)
reply.status(500).send({ error: 'Internal server error' })
})
Choosing Between Fastify, Hono, and NestJS
These three cover most production Node.js (and beyond) use cases in 2026:
| Fastify | Hono | NestJS | |
|---|---|---|---|
| Runtime | Node.js primarily | Any (Bun, Deno, Workers) | Node.js |
| Schema validation | JSON Schema / TypeBox | Zod / Valibot | class-validator |
| Learning curve | Low-medium | Low | High |
| Boilerplate | Low | Very low | High |
| Plugin ecosystem | Mature | Growing | Mature |
| OpenAPI generation | Built-in | Add-on | Add-on |
| Best for | Node.js APIs needing performance | Edge deployments, multi-runtime | Enterprise apps with Spring-like patterns |
Fastify wins when you’re committed to Node.js, want automatic OpenAPI generation, and need a mature plugin ecosystem. Hono wins when you might deploy to Workers or Bun and want maximum portability. NestJS wins when your team comes from a Java/Spring background and wants decorators and dependency injection.
Deploying
Fastify runs anywhere Node.js runs. One note: the listen call should bind to 0.0.0.0 in containers, not 127.0.0.1:
await fastify.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' })
For graceful shutdown in Kubernetes or similar:
const shutdown = async (signal: string) => {
fastify.log.info(`Received ${signal}, shutting down`)
await fastify.close()
process.exit(0)
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
fastify.close() calls the onClose hooks (where you’d close database connections) and waits for in-flight requests to complete before returning.
The case for Fastify in 2026 isn’t novelty. It’s reliability. The schema-first design catches bugs at the boundary, the OpenAPI generation keeps documentation honest, and the plugin system keeps complexity manageable as projects grow. It’s not flashy, but it’s a tool that keeps working.
Sponsored
More from this category
More from Web Development
Expo Router in 2026: File-Based Navigation That Makes React Native Feel Modern
Phoenix LiveView: Real-Time Web Features Without Writing a Line of JavaScript
Python asyncio in Production: The Pitfalls No One Warns You About
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored