Fastify JSON API: Schema Validation, Serialization, and TypeBox

Last updated:

Fastify is the fastest Node.js JSON API framework — it achieves 70,000+ req/s by compiling your route schemas into dedicated serialization and validation functions at startup. This guide covers Fastify v5: route schemas, TypeBox integration, structured errors, CORS, rate limiting, and project organization.

1. Hello World — Typed JSON Route

import Fastify from 'fastify'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { Type } from '@sinclair/typebox'

const fastify = Fastify({ logger: true }).withTypeProvider<TypeBoxTypeProvider>()

const ReplySchema = Type.Object({
  message: Type.String(),
  version: Type.Number(),
})

fastify.get('/health', {
  schema: { response: { 200: ReplySchema } },
}, async (req, reply) => {
  return { message: 'OK', version: 1 } // typed — TypeScript error if shape is wrong
})

await fastify.listen({ port: 3000 })

2. Schema-Validated POST Route with TypeBox

import { Type, Static } from '@sinclair/typebox'

const CreateUserBody = Type.Object({
  name: Type.String({ minLength: 1, maxLength: 100 }),
  email: Type.String({ format: 'email' }),
  age: Type.Optional(Type.Integer({ minimum: 0, maximum: 150 })),
})

const UserResponse = Type.Object({
  id: Type.String(),
  name: Type.String(),
  email: Type.String(),
  createdAt: Type.String(), // ISO date string
})

fastify.post('/users', {
  schema: {
    body: CreateUserBody,
    response: { 201: UserResponse },
  },
}, async (req, reply) => {
  // req.body is typed as Static<typeof CreateUserBody>
  const { name, email, age } = req.body

  const user = await db.createUser({ name, email, age })

  reply.status(201)
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    createdAt: user.createdAt.toISOString(),
  }
})

If the request body fails validation (missing name, invalid email format), Fastify automatically returns a 400 with an Ajv error message — no try/catch needed in the handler.

3. Route Params, Querystring, and Headers

const GetUserParams = Type.Object({
  userId: Type.String({ pattern: '^[0-9a-f-]{36}$' }), // UUID
})

const GetUserQuery = Type.Object({
  include: Type.Optional(Type.Array(Type.String())),
  format: Type.Optional(Type.Union([Type.Literal('full'), Type.Literal('summary')])),
})

fastify.get('/users/:userId', {
  schema: {
    params: GetUserParams,
    querystring: GetUserQuery,
    response: { 200: UserResponse, 404: Type.Object({ error: Type.String() }) },
  },
}, async (req, reply) => {
  const { userId } = req.params      // typed string
  const { include, format } = req.query  // typed

  const user = await db.getUser(userId)
  if (!user) {
    reply.status(404)
    return { error: 'User not found' }
  }
  return user
})

4. Error Handling

import createHttpError from 'http-errors'
// or: import { httpErrors } from '@fastify/sensible'

// Global error handler
fastify.setErrorHandler((error, request, reply) => {
  fastify.log.error(error)

  if (error.validation) {
    // Fastify schema validation error
    reply.status(400).send({
      error: 'Validation Error',
      message: error.message,
      details: error.validation,
    })
    return
  }

  if (error.statusCode) {
    reply.status(error.statusCode).send({ error: error.message })
    return
  }

  reply.status(500).send({ error: 'Internal Server Error' })
})

// In route handlers — throw HTTP errors
fastify.get('/secret', async (req, reply) => {
  if (!req.headers.authorization) {
    throw createHttpError(401, 'Authorization required')
  }
  // ...
})

5. CORS and Rate Limiting

import cors from '@fastify/cors'
import rateLimit from '@fastify/rate-limit'

// CORS
await fastify.register(cors, {
  origin: process.env.NODE_ENV === 'production'
    ? ['https://app.example.com']
    : true, // allow all in dev
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
})

// Rate limiting (global)
await fastify.register(rateLimit, {
  max: 100,
  timeWindow: '1 minute',
  errorResponseBuilder: (req, context) => ({
    error: 'Too Many Requests',
    message: `Rate limit exceeded, retry in ${context.after}`,
    retryAfter: context.ttl / 1000,
  }),
})

// Per-route override
fastify.post('/auth/login', {
  config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
}, loginHandler)

6. Plugin-Based Route Organization

// routes/users.ts
import type { FastifyPluginAsync } from 'fastify'

const usersPlugin: FastifyPluginAsync = async (fastify) => {
  fastify.get('/', listUsersHandler)
  fastify.get('/:userId', getUserHandler)
  fastify.post('/', createUserHandler)
  fastify.put('/:userId', updateUserHandler)
  fastify.delete('/:userId', deleteUserHandler)
}

export default usersPlugin

// server.ts
import autoLoad from '@fastify/autoload'
import path from 'path'

fastify.register(autoLoad, {
  dir: path.join(__dirname, 'routes'),
  options: { prefix: '/api/v1' },
})
// auto-registers routes/users.ts → /api/v1/users/*
// auto-registers routes/posts.ts → /api/v1/posts/*

7. Shared Schemas and $ref

// Register reusable schemas
fastify.addSchema({
  $id: 'User',
  type: 'object',
  properties: {
    id: { type: 'string' },
    name: { type: 'string' },
    email: { type: 'string' },
  },
})

// Reference in route schemas
fastify.get('/users/:id', {
  schema: {
    response: {
      200: { $ref: 'User#' },  // reference registered schema
    },
  },
}, handler)

Frequently Asked Questions

Why is Fastify faster than Express for JSON APIs?

Fastify achieves significantly higher throughput than Express for JSON APIs due to two main optimizations. First, schema-based serialization: when you provide a response JSON Schema in the route definition, Fastify uses fast-json-stringify to compile the schema into a dedicated serialization function at startup. This function is 2-5x faster than JSON.stringify because it does not need to inspect types at runtime — it knows the exact shape of the output. Second, schema-based validation: Fastify compiles request body, querystring, params, and headers schemas into Ajv validation functions at startup, avoiding per-request schema interpretation. Combined, these produce ~70,000-80,000 requests/second on a typical route vs ~20,000-30,000 for an equivalent Express route. Fastify also has a more efficient internal routing tree (radix tree vs linear matching in early Express versions) and avoids prototype pollution by default. Benchmarks at fastify.dev/benchmarks show consistent 2-4x throughput advantage over Express.

How does Fastify route schema validation work?

Every Fastify route accepts an optional schema object with keys: body (validates request body), querystring (validates query params), params (validates URL params), headers (validates request headers), and response (validates + serializes response body by status code). Each value is a JSON Schema object. At server startup, Fastify compiles these schemas into Ajv validator functions. On each request, the compiled validator runs against the incoming data before your handler executes. If validation fails, Fastify returns a 400 Bad Request with a structured error body explaining which fields failed (Ajv error messages). You never need to write validation logic inside handlers. The response schema is optional but highly recommended — it activates fast-json-stringify and also strips unknown fields from the response (a security benefit, preventing accidental data leakage). Schemas can be defined inline, registered as named schemas with fastify.addSchema(), or imported from TypeBox type definitions.

What is TypeBox and how does it work with Fastify?

TypeBox (@sinclair/typebox) is a TypeScript library that generates JSON Schema objects and the corresponding TypeScript types simultaneously. A TypeBox definition like const UserSchema = Type.Object({ id: Type.Number(), name: Type.String() }) produces both a runtime JSON Schema object (used by Fastify for validation and serialization) and a TypeScript type Static<typeof UserSchema> = { id: number; name: string }. Fastify has first-class TypeBox integration via the TypeBoxTypeProvider: fastify.withTypeProvider<TypeBoxTypeProvider>(). With this, route request and response types are automatically inferred from the schema — req.body, req.params, and the handler return type are all typed without separate TypeScript declarations. TypeBox ships with Fastify as a peer dependency since Fastify v4. It is the recommended way to write typed Fastify routes because it eliminates the duplication between TypeScript type definitions and JSON Schema objects.

How do I handle errors and return structured JSON error responses in Fastify?

Fastify has built-in structured error handling. For schema validation errors, Fastify automatically returns: {"statusCode": 400, "error": "Bad Request", "message": "body must have required property 'email'"}. For application errors, throw an HTTP error using @fastify/sensible or the built-in createError: throw fastify.httpErrors.badRequest("Email already registered"). The global error handler (fastify.setErrorHandler) intercepts all thrown errors and formats them consistently. For domain-specific errors, create custom error classes extending Error and handle them in setErrorHandler: fastify.setErrorHandler((error, request, reply) => { if (error instanceof ValidationError) { reply.status(422).send({ error: error.message, fields: error.fields }) } else { reply.status(500).send({ error: "Internal Server Error" }) } }). The response schema key "4xx" and "5xx" let you define error response shapes that fast-json-stringify serializes — but Fastify skips response serialization for errors by default unless you explicitly send through the schema.

How do I add CORS and rate limiting to a Fastify JSON API?

Both CORS and rate limiting are Fastify plugins registered with fastify.register(). For CORS: npm install @fastify/cors, then fastify.register(cors, { origin: ["https://app.example.com"], methods: ["GET","POST","PUT","DELETE"], credentials: true }). For rate limiting: npm install @fastify/rate-limit, then fastify.register(rateLimit, { max: 100, timeWindow: "1 minute" }). Rate limit options can be set globally or per-route using route.config.rateLimit. Both plugins integrate with Fastify's plugin system (based on Avvio) which handles registration order and plugin scoping. If you need CORS headers only on certain routes, register @fastify/cors in a scoped context: fastify.register((instance, opts, done) => { instance.register(cors, { origin: "*" }); instance.get("/public", handler); done() }). Rate limit state can be stored in Redis (@fastify/rate-limit supports a Redis store option) for multi-instance deployments.

How do I structure a Fastify project with multiple routes?

The idiomatic Fastify approach uses the plugin system to organize routes into separate files. Each route file exports a plugin function: export default async function usersPlugin(fastify, opts) { fastify.get("/", handler); fastify.post("/", handler) }. The main server registers these with prefix options: fastify.register(usersPlugin, { prefix: "/users" }); fastify.register(postsPlugin, { prefix: "/posts" }). For large APIs, use @fastify/autoload to automatically register all route files in a directory: fastify.register(autoLoad, { dir: path.join(__dirname, "routes"), options: { prefix: "/api/v1" } }). Fastify's encapsulation model ensures each plugin has its own scope — decorators, hooks, and schemas added inside a plugin do not leak to sibling plugins unless explicitly declared as global. This is the opposite of Express middleware, where everything is global by default. The Fastify CLI (fastify-cli) generates a project scaffold with autoload, sensible defaults, and TypeScript support out of the box.

How does Fastify handle streaming JSON responses?

Fastify supports streaming responses by returning a Node.js Readable stream or using reply.raw to access the underlying http.ServerResponse. For JSON streaming (NDJSON / JSON Lines), pipe a stream that emits newline-delimited JSON: const stream = new Readable({ read() {} }); reply.type("application/x-ndjson").send(stream); data.forEach(item => stream.push(JSON.stringify(item) + "\n")); stream.push(null). For server-sent events (SSE), set Content-Type to text/event-stream and write data: events. For large payloads you want to stream as a JSON array without buffering, use chunked transfer encoding and manually write the opening bracket, array items, and closing bracket. Note: when you stream a response, Fastify cannot run the response schema serializer — you are responsible for serialization. If you need schema-validated streaming, serialize each object with fast-json-stringify manually before pushing to the stream.

How does Fastify compare to Express and Hono for JSON APIs?

Express: mature, huge ecosystem, ~20-30k req/s for JSON routes. No built-in schema validation or typed serialization. Middleware is global by default. Still the most widely deployed Node.js framework (millions of projects). Best when you have an existing Express codebase or need maximum npm ecosystem compatibility. Fastify: ~70-80k req/s for JSON routes with schemas. Schema-first, plugin encapsulation, TypeBox integration, first-class TypeScript. Best for new Node.js backends where throughput matters and you want schema validation built in. Hono: ~140k+ req/s on Cloudflare Workers edge runtime; also runs on Node.js/Bun/Deno. Minimal bundle (12kb), TypeScript-native, zod-validator plugin. Best for edge deployments (Workers, Deno Deploy, Lambda@Edge) or when you need maximum portability across JS runtimes. Choosing: Express for legacy compatibility; Fastify for Node.js-native high-performance; Hono for edge/multi-runtime. All three serialize JSON responses, but only Fastify's schema-based fast-json-stringify provides automatic unsafe-field stripping from responses.

Further reading and primary sources