Skip to content

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

Anurag Verma

8 min read

Fastify in 2026: The Node.js API Framework That Stayed When Everyone Left

Sponsored

Share

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:

FastifyHonoNestJS
RuntimeNode.js primarilyAny (Bun, Deno, Workers)Node.js
Schema validationJSON Schema / TypeBoxZod / Valibotclass-validator
Learning curveLow-mediumLowHigh
BoilerplateLowVery lowHigh
Plugin ecosystemMatureGrowingMature
OpenAPI generationBuilt-inAdd-onAdd-on
Best forNode.js APIs needing performanceEdge deployments, multi-runtimeEnterprise 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

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored