APIs
|stacknotice.com
15 min left|
0%
|3,000 words
APIs

Fastify Complete Guide: Fast Node.js APIs in 2026

Fastify is the fastest Node.js web framework — 2x faster than Express with built-in schema validation, TypeScript support, and a plugin system that scales. Practical guide with real code.

C
Carlos Oliva
Software Developer
July 30, 202615 min read
Share:
Fastify Complete Guide: Fast Node.js APIs in 2026

Fastify is the fastest HTTP framework for Node.js. Not marginally faster — benchmarks consistently show 2x the throughput of Express on identical hardware, thanks to a lean core, JSON schema validation that compiles to optimized code, and a serialization engine that avoids the overhead of JSON.stringify.

Beyond raw speed, Fastify is TypeScript-first, has a plugin system designed for large applications, and ships with schema-based request validation and response serialization built in — things that require separate packages (Zod, express-validator, ajv) in an Express app.

This guide covers Fastify from scratch through production patterns: routing, validation, plugins, hooks, authentication, and testing.

Why Fastify Over Express

Express problems in 2026:

  • No built-in request validation — you add zod, joi, or express-validator manually
  • No schema-based response serialization — JSON.stringify every response
  • No TypeScript-first design — types are bolted on via @types/express
  • Callback-based API — async support works but wasn't designed for it
  • No plugin isolation — middleware is global, no scoping

What Fastify does differently:

  • Built-in JSON Schema validation for request body, params, query, and headers
  • Compiled serialization — Fastify generates optimized serializers per route, 2x faster response encoding
  • Plugin system with encapsulation — plugins register routes and decorators in scope, not globally
  • Async/await native — every route handler returns a value, no res.send() required
  • Full TypeScript generics — route params, body, and response types flow through

When Express still makes sense:

  • Extremely simple proxy or middleware servers
  • Large existing Express codebases where migration cost exceeds benefit
  • Teams more comfortable with middleware patterns than plugin systems

For new Node.js APIs, Fastify is the better default unless you're also considering Bun + Hono, which goes further in performance.

Installation

npm install fastify
npm install -D @types/node typescript tsx
 
# Optional but recommended
npm install @fastify/sensible  # 404/500 shortcuts
npm install @fastify/cors
npm install @fastify/jwt
npm install @fastify/multipart

First Server

// src/server.ts
import Fastify from 'fastify'
 
const server = Fastify({
  logger: {
    level: 'info',
    transport: process.env.NODE_ENV === 'development'
      ? { target: 'pino-pretty' }
      : undefined
  }
})
 
server.get('/', async (request, reply) => {
  return { status: 'ok', timestamp: Date.now() }
})
 
server.get<{
  Params: { id: string }
}>('/users/:id', async (request, reply) => {
  const { id } = request.params
  // TypeScript knows id is a string
  return { id, name: 'Alice' }
})
 
const start = async () => {
  try {
    await server.listen({ port: 3000, host: '0.0.0.0' })
    console.log('Server running at http://localhost:3000')
  } catch (err) {
    server.log.error(err)
    process.exit(1)
  }
}
 
start()

Run with:

npx tsx src/server.ts
# or in watch mode:
npx tsx --watch src/server.ts

Note: you don't call reply.send() — just return the value. Fastify handles serialization automatically.

Schema Validation

Fastify validates requests and serializes responses using JSON Schema. Define the schema on each route:

import Fastify, { FastifyInstance } from 'fastify'
import { Type, Static } from '@sinclair/typebox'
 
const server = Fastify({ logger: true })
 
// Define schemas with TypeBox (type-safe JSON Schema)
const CreateUserBody = Type.Object({
  name: Type.String({ minLength: 1 }),
  email: Type.String({ format: 'email' }),
  role: Type.Union([
    Type.Literal('admin'),
    Type.Literal('user')
  ], { default: 'user' })
})
 
const UserResponse = Type.Object({
  id: Type.String(),
  name: Type.String(),
  email: Type.String(),
  role: Type.String(),
  createdAt: Type.String()
})
 
type CreateUserBody = Static<typeof CreateUserBody>
type UserResponse = Static<typeof UserResponse>
 
server.post<{
  Body: CreateUserBody
  Reply: UserResponse
}>('/users', {
  schema: {
    body: CreateUserBody,
    response: {
      201: UserResponse
    }
  }
}, async (request, reply) => {
  const { name, email, role } = request.body
 
  // Body is fully typed — TypeScript knows name, email, role
  const user = await db.user.create({ data: { name, email, role } })
 
  reply.status(201)
  return user  // serialized against UserResponse schema
})

What validation does for you:

  • Sends 400 with detailed error messages if the request body is invalid
  • Strips extra fields not in the schema (by default)
  • Converts types (e.g., query string ?limit=10 comes as string, schema can coerce to number)
  • Response serialization omits fields not in the response schema — prevents accidentally leaking sensitive data

Install TypeBox:

npm install @sinclair/typebox

Routing and Route Options

// GET with query params
server.get<{
  Querystring: { page?: number; limit?: number; search?: string }
}>('/posts', {
  schema: {
    querystring: Type.Object({
      page: Type.Optional(Type.Integer({ minimum: 1, default: 1 })),
      limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 20 })),
      search: Type.Optional(Type.String())
    })
  }
}, async (request) => {
  const { page = 1, limit = 20, search } = request.query
  const posts = await db.post.findMany({
    where: search ? { title: { contains: search } } : { published: true },
    skip: (page - 1) * limit,
    take: limit
  })
  return { posts, page, limit }
})
 
// DELETE with params
server.delete<{
  Params: { id: string }
  Reply: { success: boolean }
}>('/posts/:id', async (request, reply) => {
  const { id } = request.params
  await db.post.delete({ where: { id } })
  reply.status(204)
  return { success: true }
})
 
// PATCH with both params and body
server.patch<{
  Params: { id: string }
  Body: { title?: string; published?: boolean }
}>('/posts/:id', {
  schema: {
    params: Type.Object({ id: Type.String() }),
    body: Type.Object({
      title: Type.Optional(Type.String({ minLength: 1 })),
      published: Type.Optional(Type.Boolean())
    })
  }
}, async (request) => {
  const { id } = request.params
  return await db.post.update({ where: { id }, data: request.body })
})

Plugin System

Plugins are Fastify's version of Express routers — but with encapsulation. A plugin can register routes, decorators, and hooks that are scoped to that plugin's context, not the entire application.

// src/plugins/database.ts
import fp from 'fastify-plugin'
import { PrismaClient } from '@prisma/client'
 
// fp() makes the plugin available in parent scope (not encapsulated)
export default fp(async (fastify) => {
  const prisma = new PrismaClient()
  await prisma.$connect()
 
  fastify.decorate('db', prisma)
 
  fastify.addHook('onClose', async () => {
    await prisma.$disconnect()
  })
})
 
// Extend Fastify type so TypeScript knows about db
declare module 'fastify' {
  interface FastifyInstance {
    db: PrismaClient
  }
}
// src/routes/posts.ts
import { FastifyPluginAsync } from 'fastify'
import { Type } from '@sinclair/typebox'
 
const postsRouter: FastifyPluginAsync = async (fastify) => {
  // fastify.db is available here because the db plugin is registered globally
  fastify.get('/posts', async () => {
    return await fastify.db.post.findMany({ where: { published: true } })
  })
 
  fastify.post('/posts', {
    schema: {
      body: Type.Object({
        title: Type.String({ minLength: 1 }),
        content: Type.String()
      })
    }
  }, async (request, reply) => {
    const post = await fastify.db.post.create({ data: request.body })
    reply.status(201)
    return post
  })
}
 
export default postsRouter
// src/server.ts
import Fastify from 'fastify'
import databasePlugin from './plugins/database'
import postsRouter from './routes/posts'
 
const server = Fastify({ logger: true })
 
// Register plugins — order matters
await server.register(databasePlugin)
await server.register(postsRouter, { prefix: '/api' })
 
await server.listen({ port: 3000, host: '0.0.0.0' })

Hooks

Fastify has a lifecycle with hooks at each stage:

// onRequest — runs before routing
server.addHook('onRequest', async (request, reply) => {
  request.log.info({ url: request.url }, 'incoming request')
})
 
// preHandler — runs after routing, before the handler
server.addHook('preHandler', async (request, reply) => {
  // Authentication check
  if (request.routeOptions.config?.requiresAuth) {
    const token = request.headers.authorization?.split(' ')[1]
    if (!token) {
      return reply.status(401).send({ error: 'Unauthorized' })
    }
    // Verify token and attach user...
  }
})
 
// onSend — runs after handler, before sending response
server.addHook('onSend', async (request, reply, payload) => {
  reply.header('X-Response-Time', Date.now() - request.startTime)
  return payload
})
 
// onError — global error handler
server.addHook('onError', async (request, reply, error) => {
  request.log.error(error)
})

Authentication with JWT

npm install @fastify/jwt
import jwt from '@fastify/jwt'
 
server.register(jwt, {
  secret: process.env.JWT_SECRET!,
  sign: { expiresIn: '7d' }
})
 
// Login route — generates token
server.post<{
  Body: { email: string; password: string }
}>('/auth/login', {
  schema: {
    body: Type.Object({
      email: Type.String({ format: 'email' }),
      password: Type.String({ minLength: 8 })
    })
  }
}, async (request, reply) => {
  const { email, password } = request.body
  const user = await db.user.findUnique({ where: { email } })
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return reply.status(401).send({ error: 'Invalid credentials' })
  }
 
  const token = server.jwt.sign({ id: user.id, email: user.email, role: user.role })
  return { token, user: { id: user.id, email: user.email } }
})
 
// Protect routes with a decorator
server.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => {
  try {
    await request.jwtVerify()
  } catch {
    reply.status(401).send({ error: 'Unauthorized' })
  }
})
 
// Protected route
server.get('/api/profile', {
  preHandler: [server.authenticate]
}, async (request) => {
  return request.user  // typed as the JWT payload
})

Error Handling

import sensible from '@fastify/sensible'
 
server.register(sensible)
 
// Custom error handler
server.setErrorHandler((error, request, reply) => {
  const statusCode = error.statusCode ?? 500
 
  if (statusCode >= 500) {
    request.log.error(error)
  }
 
  reply.status(statusCode).send({
    error: statusCode >= 500 ? 'Internal Server Error' : error.message,
    statusCode
  })
})
 
// Using sensible shortcuts
server.get('/posts/:id', async (request, reply) => {
  const post = await db.post.findUnique({ where: { id: request.params.id } })
  if (!post) {
    return reply.notFound('Post not found')  // 404 via sensible
  }
  return post
})

Testing

Fastify has a built-in inject method for testing without starting a real HTTP server:

// src/server.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { buildServer } from './server'
 
let server: Awaited<ReturnType<typeof buildServer>>
 
beforeAll(async () => {
  server = await buildServer()
})
 
afterAll(async () => {
  await server.close()
})
 
describe('POST /users', () => {
  it('creates a user with valid data', async () => {
    const response = await server.inject({
      method: 'POST',
      url: '/users',
      payload: {
        name: 'Alice',
        email: 'alice@example.com'
      }
    })
 
    expect(response.statusCode).toBe(201)
    const body = response.json()
    expect(body.id).toBeDefined()
    expect(body.email).toBe('alice@example.com')
  })
 
  it('returns 400 for invalid email', async () => {
    const response = await server.inject({
      method: 'POST',
      url: '/users',
      payload: { name: 'Bob', email: 'not-an-email' }
    })
 
    expect(response.statusCode).toBe(400)
  })
 
  it('returns 400 for missing name', async () => {
    const response = await server.inject({
      method: 'POST',
      url: '/users',
      payload: { email: 'test@example.com' }
    })
 
    expect(response.statusCode).toBe(400)
  })
})
 
describe('GET /users/:id', () => {
  it('returns 404 for non-existent user', async () => {
    const response = await server.inject({
      method: 'GET',
      url: '/users/non-existent-id'
    })
 
    expect(response.statusCode).toBe(404)
  })
})

The buildServer factory pattern makes testing clean:

// src/server.ts
export async function buildServer() {
  const server = Fastify({ logger: false })  // disable logging in tests
 
  await server.register(databasePlugin)
  await server.register(postsRouter, { prefix: '/api' })
 
  return server
}
 
// Production entry point
if (process.env.NODE_ENV !== 'test') {
  const server = await buildServer()
  await server.listen({ port: 3000, host: '0.0.0.0' })
}

Fastify vs Express vs Hono

FastifyExpressHono
Performance~75k req/s~35k req/s~100k+ req/s
TypeScriptFirst-class@types/expressFirst-class
ValidationBuilt-in (JSON Schema)External (zod/joi)zValidator middleware
SerializationCompiled, fastJSON.stringifyJSON.stringify
Plugin systemEncapsulated scopeGlobal middlewareMiddleware stack
RuntimeNode.jsNode.jsMulti-runtime (Bun, Deno, Edge)
EcosystemMatureVery matureGrowing
Learning curveMediumLowLow-Medium
Best forHigh-throughput Node.js APIsLegacy apps, simple serversEdge/Bun/multi-runtime APIs

Rule of thumb:

  • Staying on Node.js, need high throughput → Fastify
  • Multi-runtime or Bun → Hono (see Hono.js Complete Guide)
  • Large existing codebase → stick with Express and migrate gradually
  • Greenfield API server → Fastify if Node.js, Hono if Bun

Fastify's sweet spot is high-throughput Node.js APIs where the built-in schema validation and compiled serialization earn their keep. The plugin system scales better than Express middleware for large teams — encapsulation means plugins don't interfere with each other. If you're writing a new backend service and staying on Node.js, Fastify is the upgrade from Express that actually delivers measurable gains without a framework migration cost.

#fastify#nodejs#typescript#api#backend
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.