How to Use Fastify: The Beginner's Guide (First Server, Routes, Schema Validation, Plugins, and the Production Listen)

CodexFuse Team · 2026-10-31T03:57:27.257Z · 14 min read

What Is Fastify, and Why Do Node Developers Switch to It?

Learning how to use Fastify starts with understanding what it is: a web framework for Node.js built for speed and a clean developer experience. It handles HTTP routing, JSON parsing, validation, logging, and plugins out of the box, so you write less boilerplate than with a bare-bones setup and get more structure than a minimal router gives you. This Fastify tutorial walks you from your first server all the way to a production-ready deploy.

Developers usually switch to Fastify for three concrete reasons, and they matter most if this is your Fastify for beginners starting point. First, JSON Schema validation is built into every route — request bodies, query strings, and responses are checked automatically, which catches bad input before your handler runs. Second, the plugin system keeps large apps organized through encapsulation instead of one giant server file. Third, logging via Pino is included, so structured logs work from the first line of code with no extra setup.

If you are learning Fastify as a beginner, the fastest way to test each route you build in this guide is with a REST API tester — paste your endpoint, send GET and POST requests, and inspect the JSON responses without writing any test code yet.

How Do You Install Fastify and Start Your First Server?

You install Fastify with npm and start a working server in under five minutes. This numbered walkthrough assumes you already have Node.js 20 or newer installed — check with node -v before you begin.

  1. Create a project folder and initialize it. Run mkdir fastify-api, then cd fastify-api and npm init -y. This creates your package.json; if you want a hand with the fields, a package.json generator builds a correct one in seconds.
  2. Install Fastify. Run npm i fastify. As of this writing the current release is Fastify 5.12.7 (verified on npm, October 30, 2026).
  3. Enable ES modules. Add "type": "module" to your package.json so you can use import syntax, which is what this guide uses throughout.
  4. Create server.js with the first-server code below.
  5. Run it with node server.js, then open http://localhost:3000 in your browser — you should see {"hello":"world"}.
// server.js
import Fastify from 'fastify'

const fastify = Fastify({ logger: true })

fastify.get('/', async (request, reply) => {
  return { hello: 'world' }
})

const start = async () => {
  try {
    await fastify.listen({ port: 3000 })
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

Three things in this snippet deserve attention. Fastify({ logger: true }) turns on structured logging from the start — every request gets logged automatically. Returning an object from a handler sends it as JSON with no reply.send() needed. And fastify.listen({ port: 3000 }) uses the options-object form, which is the current Fastify v5 signature (the old positional listen(3000, '0.0.0.0') style from v4-era tutorials still appears in old blog posts — do not copy it).

How Do Fastify Routes Work?

Fastify routes map an HTTP method and a URL pattern to an async handler function. You declare them with shorthand methods — fastify.get(), fastify.post(), fastify.put(), fastify.delete(), fastify.patch() — and each handler receives a request object and a reply object.

Route parameters, query strings, and JSON bodies each live in their own predictable place. Parameters like :id arrive in request.params, ?q=search values arrive in request.query, and a posted JSON body arrives already parsed in request.body — Fastify parses JSON bodies by default with no middleware to install.

// Route parameter: GET /users/42
fastify.get('/users/:id', async (request, reply) => {
  const { id } = request.params
  return { userId: id }
})

// Query string: GET /search?q=fastify
fastify.get('/search', async (request, reply) => {
  const { q } = request.query
  return { resultsFor: q || 'nothing' }
})

// JSON body: POST /users  { "name": "Ada", "email": "[email protected]" }
fastify.post('/users', async (request, reply) => {
  const { name, email } = request.body
  reply.code(201)
  return { id: 1, name, email }
})

Use reply.code(201) before returning whenever a route creates something — 201 Created is the correct status for a successful POST that makes a new resource. Fastify sets 200 OK by default when you just return a value.

How do you organize routes as the app grows?

You move routes into separate files and register them with a URL prefix. Keeping every route in server.js works for a tutorial, but a real API with dozens of endpoints becomes unreadable within a week — one file per resource is the habit that scales.

// routes/users.js
async function userRoutes (fastify, options) {
  fastify.get('/users', async () => {
    return [{ id: 1, name: 'Ada' }]
  })

  fastify.get('/users/:id', async (request) => {
    return { id: request.params.id, name: 'Ada' }
  })
}

export default userRoutes

// server.js
import userRoutes from './routes/users.js'
fastify.register(userRoutes, { prefix: '/api/v1' })

The prefix option mounts every route in the file under /api/v1, so /users becomes /api/v1/users with no path editing inside the route file. This is the same register mechanism Fastify plugins use, which the plugins section below explains in full.

How Does Fastify Schema Validation Work?

Fastify validates request data against JSON Schema definitions you attach to each route. You declare a schema object with body, querystring, params, and response sections, and Fastify rejects invalid requests with a 400 error before your handler ever runs.

This is the feature that most clearly separates Fastify from Express. In Express, validation is something you add with a separate library and wire up yourself; in Fastify it is part of the route definition, so the contract for every endpoint lives in exactly one place and stays in sync with the code.

const createUserSchema = {
  body: {
    type: 'object',
    required: ['name', 'email'],
    properties: {
      name: { type: 'string', minLength: 2 },
      email: { type: 'string', format: 'email' }
    }
  },
  response: {
    201: {
      type: 'object',
      properties: {
        id: { type: 'number' },
        name: { type: 'string' },
        email: { type: 'string' }
      }
    }
  }
}

fastify.post('/users', { schema: createUserSchema }, async (request, reply) => {
  const { name, email } = request.body
  reply.code(201)
  return { id: 1, name, email }
})

Send a POST with a missing email and Fastify responds 400 Bad Request with a message naming the missing field — your handler never executes. The response schema works the other direction: Fastify strips any extra properties your handler returns that are not in the schema, which prevents accidentally leaking internal fields like password hashes, and it also speeds up JSON serialization.

Should you validate query strings and params too?

Yes — validate everything that crosses the network boundary. A querystring schema with { type: 'object', properties: { q: { type: 'string' } } } turns a missing search term into a clear 400 instead of a confusing empty result, and a params schema guarantees :id matches the shape your database expects.

What Are Fastify Plugins and How Do You Use Them?

Fastify plugins are functions that receive the Fastify instance and register routes, decorators, or hooks inside their own encapsulated scope. You add them with fastify.register(plugin, options), and Fastify loads them in declaration order — each plugin finishes loading before the next one starts.

Encapsulation is the key idea. A plugin sees everything registered in its parent scope, but anything it registers stays private to itself and its children. That means two plugins can each declare a /status route or a db decorator without colliding — a property of large codebases that Express's flat middleware stack cannot give you.

// plugins/db.js — shared database connection, loaded first
import fastifyPlugin from 'fastify-plugin'

async function dbConnector (fastify, options) {
  fastify.decorate('db', {
    users: [{ id: 1, name: 'Ada' }]
  })
}

export default fastifyPlugin(dbConnector)

// server.js — loading order matters
import dbConnector from './plugins/db.js'
import userRoutes from './routes/users.js'

fastify.register(dbConnector)
fastify.register(userRoutes, { prefix: '/api/v1' })

Wrapping with fastify-plugin deliberately breaks encapsulation for that one plugin, exposing its decorators to the parent scope — that is how fastify.db becomes visible inside your route files. Use it for cross-cutting concerns like database connections and configuration; leave ordinary route files encapsulated so their internals stay private.

The official docs recommend a consistent loading order: ecosystem plugins first, then your own plugins, then decorators, then hooks, then services. Following that order means every file can rely on everything registered above it, which eliminates an entire class of "undefined at startup" bugs.

How Do Hooks, Logging, and Error Handling Work?

Fastify hooks are lifecycle functions that run at fixed points around each request — onRequest, preHandler, onResponse, and others. You add them with fastify.addHook(name, handler), and they are the right place for timing, authentication checks, and request logging.

// Time every request
fastify.addHook('onRequest', async (request, reply) => {
  request.startTime = Date.now()
})

fastify.addHook('onResponse', async (request, reply) => {
  const ms = Date.now() - request.startTime
  fastify.log.info(`${request.method} ${request.url} — ${ms}ms`)
})

Logging is built on Pino, which is why fastify.log.info() produces structured JSON lines instead of plain text — log aggregators parse them without extra configuration. For prettier local output during development, install pino-pretty and pass a transport config to the logger option.

Centralize error handling instead of try/catching in every route. fastify.setErrorHandler() catches anything a handler throws, and fastify.setNotFoundHandler() replaces the default 404 response with your own shape.

fastify.setErrorHandler((error, request, reply) => {
  fastify.log.error(error)
  const message = process.env.NODE_ENV === 'production'
    ? 'Internal server error'
    : error.message
  reply.code(error.statusCode || 500).send({ error: message })
})

fastify.setNotFoundHandler((request, reply) => {
  reply.code(404).send({ error: 'Route not found' })
})

Note the production guard: detailed error messages help you debug locally but leak internals to attackers in production, so the handler above only exposes them outside production.

Fastify vs Express: What Is Actually Different?

Fastify and Express solve the same problem — HTTP servers for Node.js — but differ in what they include by default. Express is deliberately minimal: routing plus middleware, with everything else (validation, logging, structure) left to libraries you choose. Fastify ships routing, JSON Schema validation, Pino logging, and an encapsulation-based plugin system in the core.

The honest comparison for a beginner is effort, not speed. A hello-world server takes the same handful of lines in both frameworks. Fastify pulls ahead the moment you need input validation, structured logs, or a multi-file codebase — those are one-line features in Fastify and integration projects in Express. Benchmarks consistently show Fastify handling more requests per second, but for most applications the developer-experience gap matters far more than the throughput gap.

Choose Fastify when you are building a JSON API and want validation and structure without assembling a stack yourself. Stay with Express when you are maintaining an existing Express codebase, need a specific middleware that has no Fastify equivalent, or are following a tutorial series built on it. Neither choice is wrong — they are different defaults for the same job.

FeatureFastifyExpress
JSON Schema validationBuilt in, per routeSeparate library (e.g. Zod, Joi)
LoggingBuilt in (Pino)Separate library (e.g. Morgan, Pino)
Async handlersNative, errors caught automaticallyNeeds wrapper or Express 5
Code organizationEncapsulated pluginsFlat middleware stack
JSON body parsingBuilt inBuilt in (express.json)
TypeScript supportFirst-class genericsCommunity types

How Do You Use Fastify with TypeScript?

Fastify TypeScript support is first-class thanks to generics on every route. You install typescript and @types/node as dev dependencies, then type your params, query strings, and bodies directly in the route definition — no separate interface files required for simple endpoints.

import Fastify, { FastifyInstance } from 'fastify'

const fastify: FastifyInstance = Fastify({ logger: true })

interface UserParams { id: string }
interface CreateUserBody { name: string; email: string }

fastify.get<{ Params: UserParams }>('/users/:id', async (request, reply) => {
  // request.params.id is typed as string here
  return { userId: request.params.id }
})

fastify.post<{ Body: CreateUserBody }>('/users', async (request, reply) => {
  // request.body is typed as CreateUserBody here
  reply.code(201)
  return { id: 1, ...request.body }
})

The generics available are Body, Querystring, Params, Headers, and Reply — type exactly the parts of the request your handler touches. Pair these with a matching JSON Schema and you get validation at runtime plus autocompletion at write time, which is the combination that makes refactors safe.

If you are converting existing JavaScript types to TypeScript interfaces while migrating, a JSON to TypeScript converter turns sample API payloads into interfaces you can paste straight into your route files.

How Do You Run Fastify in Production?

Running Fastify in production means binding to all network interfaces, reading the port from the environment, and starting with a process manager. The single most common deployment bug is listening on 127.0.0.1 — Fastify's default — inside a Docker container or VPS, where that address is unreachable from outside.

const start = async () => {
  try {
    const port = Number(process.env.PORT) || 3000
    const host = process.env.HOST || '0.0.0.0'
    await fastify.listen({ port, host })
    fastify.log.info(`Server listening on ${host}:${port}`)
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

Set HOST=0.0.0.0 (or pass it directly) on any VPS, container, or platform-as-a-service deploy — the docs explicitly recommend 0.0.0.0 for Docker and :: if you need IPv6. Keep 127.0.0.1 only for local development, where binding to localhost is a small security bonus.

For process management, run the app under PM2, systemd, or your platform's supervisor so crashes restart automatically, and put a reverse proxy (Nginx or Caddy) in front for TLS termination. Wire the whole flow — install, lint, test, deploy — into CI with a GitHub Actions workflow generator so every push to main redeploys the same way.

What about the Fastify CLI?

The fastify-cli package (npm i -g fastify-cli) scaffolds projects and runs them with conveniences like automatic route loading from a directory. It is worth trying once you are comfortable with the manual setup above — but learn the manual listen() flow first, because the CLI hides the exact startup sequence you will need to debug in production.

Where Do You Go Next?

You now have the complete beginner arc: install, first server, routes, schema validation, plugins, hooks, logging, errors, the Express comparison, TypeScript, and the production listen. The natural next steps are picking a database — our Supabase beginner guide pairs well with a Fastify API — and learning to test your endpoints properly with our Postman beginner guide.

Sources

  • Fastify official documentation — Getting Started (fastify.dev/docs/latest/Guides/Getting-Started/) — install steps, first server, ESM/CommonJS examples, listen({ port, host }) signature, default 127.0.0.1 binding, 0.0.0.0 for Docker, plugin loading order, JSON Schema validation. Fetched October 30, 2026.
  • npm registry — fastify package metadata (registry.npmjs.org/fastify/latest) — current version 5.12.7. Fetched October 30, 2026.
  • Platformatic Blog — "Fastify Fundamentals: Building Your First Route" (blog.platformatic.dev) — install → first route → curl test walkthrough, Pino logger setup. Search result, October 30, 2026.
  • DesiProgrammer — "Let's learn Fastify.js" (desiprogrammer.com) — routing, route prefixes, query parameters, JSON body parsing. Search result, October 30, 2026.

Frequently Asked Questions

Is Fastify better than Express for beginners?

Fastify is arguably friendlier for API beginners because validation, logging, and JSON parsing work out of the box — there is less to assemble before you can build. Express has more tutorials available because it is older, so absolute beginners who learn best from video courses may still start there. Either way, the concepts transfer: routes, handlers, and middleware thinking apply to both.

Do I need to learn JSON Schema to use Fastify?

No — Fastify works fine without any schemas, and you can add them route by route as you learn. But learning the basics (type, required, properties) pays off quickly: it is a small syntax that gives you free input validation, clearer API contracts, and faster JSON serialization on every endpoint you add it to.

Can Fastify replace Express in an existing project?

Technically yes, since both are Node.js HTTP frameworks, but a migration is a rewrite of the server layer, not a drop-in swap — middleware, plugins, and validation all have Fastify equivalents with different APIs. Most teams adopt Fastify for new services and leave working Express apps alone, which is the pragmatic call.

Does Fastify work with TypeScript out of the box?

Yes. Fastify ships its own type definitions with generics for request and reply shapes, so npm i -D typescript @types/node is the only setup needed. Type your route params, bodies, and query strings with the Body, Params, and Querystring generics and you get full autocompletion in handlers.

Why is my Fastify server unreachable inside Docker?

Because Fastify defaults to listening on 127.0.0.1, which inside a container means "only this container." Pass host: '0.0.0.0' in the listen() options (or set HOST=0.0.0.0) so the server binds to all interfaces, then map the port with -p 3000:3000. This is the single most common Fastify deployment gotcha.

Featured on Twelve Tools Launch Llama Tools Launch your MCP