Skip to content

Web Development · Backend

Fastify in 2026: Still the Right Node.js API Pick

While the ecosystem chased Bun, Deno, and edge runtimes, Fastify stayed the production choice for high-throughput Node APIs. Why it still holds up.

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 strong 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.

Frequently asked questions

What does schema-first actually buy me over Express plus a validator?
Three things from one declaration. Requests get validated and rejected with a structured 400 before your handler runs. Responses get serialized by precompiled code, which is meaningfully faster than JSON.stringify. And the OpenAPI spec generates from the same schema, so your documentation is derived from the thing that actually enforces the contract rather than written alongside it and left to rot.
Why does the response schema prevent data leaks?
Because serialization is allow-list based. fast-json-stringify emits only the fields the schema declares, so if a handler accidentally returns a full user record including a password hash or an internal flag, the field is not in the schema and never reaches the wire. It turns a whole category of accidental exposure into something the framework catches by construction rather than something review has to notice.
When should I wrap a plugin in fastify-plugin?
When the thing it registers needs to be visible outside its own scope. Fastify isolates plugin scopes by default, so a decorator registered inside a plugin does not leak to sibling plugins, which is what keeps a large codebase from becoming one shared namespace. A database connection is the standard exception: you want every route to see it, so you wrap that plugin in fp to opt out of the isolation deliberately.
Fastify, Hono, or NestJS?
Fastify when you are committed to Node.js, want OpenAPI generation for free, and need a mature plugin ecosystem. Hono when portability matters, because it runs on Workers, Bun, and Deno from the same code. NestJS when the team comes from Java or Spring and wants decorators and dependency injection, accepting a lot more boilerplate and a steeper learning curve for that structure.
What do I need to change for a container deployment?
Two things. Bind to 0.0.0.0 rather than the default loopback, or nothing outside the container can reach the process. And handle SIGTERM by calling fastify.close(), which runs your onClose hooks so database connections shut down cleanly and waits for in-flight requests to finish before resolving. Without that second part, a rolling deploy drops whatever requests were mid-flight.

Sponsored

Sponsored

Discussion

Join the conversation.

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

Sponsored