Skip to content

Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors

FixDevs ·

Quick Answer

How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.

The Error

You register a route in Fastify but it returns 404:

fastify.register(async function plugin(fastify) {
  fastify.decorate('db', myDatabase);
});

fastify.get('/users', async (request, reply) => {
  return fastify.db.findAll(); // TypeError: Cannot read properties of undefined (reading 'findAll')
});

Or the server crashes mid-request:

FastifyError [Error]: Reply was already sent
    FST_ERR_REP_ALREADY_SENT

Or request.body is undefined even though you sent JSON:

fastify.post('/data', async (request, reply) => {
  console.log(request.body); // undefined
});

Or TypeScript gives you unknown types everywhere:

fastify.post('/users', async (request) => {
  const email = request.body.email; // Error: Object is of type 'unknown'
});

All of these trace back to a small set of Fastify-specific concepts that trip up developers coming from Express.

Why This Happens

Fastify uses a plugin encapsulation model that doesn’t exist in Express. When you call fastify.register(), Fastify creates a new child scope. Routes and decorators registered in that scope are invisible to sibling scopes and to the parent scope. Child scopes inherit from parents, but parents can’t see into children.

This encapsulation is intentional — it makes large apps modular and prevents plugins from accidentally clobbering each other. But it’s the root cause of most “my code worked yesterday and doesn’t today” Fastify bugs.

Understanding encapsulation solves about 80% of Fastify problems.

Fix 1: Route Returns 404 — Plugin Encapsulation

If you register a route inside a plugin and get 404, or register a decorator inside a plugin and it’s undefined when you use it outside, the encapsulation scope is the culprit.

The problem:

// Plugin registers a decorator
fastify.register(async function dbPlugin(fastify) {
  fastify.decorate('db', { query: () => {} });
  // Decorator is scoped to this plugin — invisible to parent
});

// Parent scope tries to use it — doesn't exist here
fastify.get('/users', async (request, reply) => {
  return fastify.db.query(); // TypeError: fastify.db is undefined
});

Fix: Use fastify-plugin to break encapsulation for shared utilities:

const fp = require('fastify-plugin');

const dbPlugin = fp(async function(fastify) {
  fastify.decorate('db', { query: () => {} });
  // fp() makes this decorator available to the parent and all siblings
});

fastify.register(dbPlugin);

fastify.get('/users', async (request, reply) => {
  return fastify.db.query(); // Works — decorator escaped scope
});

fastify-plugin (fp) adds a special symbol to your plugin that tells Fastify to skip encapsulation. The decorator gets promoted to the parent scope and is available everywhere registered after it.

Rule of thumb: Use fp() for plugins that add decorators, database connections, or global hooks. Don’t use fp() for plugins that define routes with a prefix — fp makes the prefix option meaningless because there’s no scope to apply it to.

Common Mistake: Developers coming from Express assume all registered plugins share a flat namespace. In Fastify, each fastify.register() call is a new isolated scope. This is the single most important concept to internalize. If something is undefined when you expect it to be defined, draw out the scope tree — the answer is almost always there.

Scoped routes with prefixes work correctly:

// This is correct — routes stay in their own scope with the prefix
fastify.register(async function(fastify) {
  fastify.get('/list', handler);    // Responds at /api/list
  fastify.post('/create', handler); // Responds at /api/create
}, { prefix: '/api' });

Fix 2: FST_ERR_REP_ALREADY_SENT — Reply Was Already Sent

This error means your route handler attempted to send a response twice. Fastify (unlike Express) prevents this — the second send throws FST_ERR_REP_ALREADY_SENT.

The most common cause: mixing return with reply.send():

// WRONG — sends the response twice
fastify.get('/data', async (request, reply) => {
  const data = await fetchData();
  reply.send(data);    // First send
  return data;         // Second send — throws FST_ERR_REP_ALREADY_SENT
});

In Fastify async routes, returning a value from the handler is equivalent to calling reply.send(). Pick one style and stick with it:

// Style 1: return the value (recommended for async routes)
fastify.get('/data', async (request, reply) => {
  const data = await fetchData();
  return data; // Fastify calls reply.send(data) for you
});

// Style 2: call reply.send() explicitly (and don't return a value)
fastify.get('/data', async (request, reply) => {
  const data = await fetchData();
  reply.send(data);
  // No return after this
});

Another common cause: a hook sends a response and the route handler also sends one:

fastify.addHook('preHandler', async (request, reply) => {
  if (!request.headers.authorization) {
    reply.code(401).send({ error: 'Unauthorized' }); // Sends response here
    // Execution continues into the route handler unless you return
  }
});

fastify.get('/protected', async (request, reply) => {
  return { secret: 'data' }; // Also sends — FST_ERR_REP_ALREADY_SENT
});

In async hooks, you must return reply after sending to stop execution:

fastify.addHook('preHandler', async (request, reply) => {
  if (!request.headers.authorization) {
    reply.code(401).send({ error: 'Unauthorized' });
    return reply; // Stops the request lifecycle — route handler won't run
  }
});

Fix 3: request.body Is Undefined

You’re sending JSON but request.body is undefined in your handler. The two most common causes:

1. Missing Content-Type: application/json header:

Fastify won’t parse the body unless the request includes a Content-Type header that matches a registered parser. JSON is handled by default, but only when the header is present.

# Wrong — no Content-Type
curl -X POST http://localhost:3000/data -d '{"name":"Alice"}'

# Correct
curl -X POST http://localhost:3000/data \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice"}'

In JavaScript/TypeScript fetch:

// Wrong
fetch('/data', { method: 'POST', body: JSON.stringify({ name: 'Alice' }) });

// Correct
fetch('/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice' })
});

2. Accessing request.body in the wrong lifecycle hook:

Body parsing happens after onRequest and preParsing. If you try to read request.body in those early hooks, it’s always undefined:

// WRONG — body not parsed yet
fastify.addHook('onRequest', async (request, reply) => {
  console.log(request.body); // undefined, always
});

// CORRECT — body is available from preValidation onward
fastify.addHook('preValidation', async (request, reply) => {
  console.log(request.body); // Has the parsed body
});

Fastify hook order: onRequestpreParsingpreValidationpreHandler → route handler → onSendonResponse.

Fix 4: FST_ERR_VALIDATION — Schema Validation Fails

When you define a schema and a request doesn’t match it, Fastify rejects the request with a 400 and an error like:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "body must have required property 'email'"
}

This is correct behavior — the client sent a bad request. But there are cases where the schema itself causes the error:

TypeBox strict mode error ("unknown keyword: 'kind'"):

If you use TypeBox schemas without the type provider, Ajv’s strict mode rejects TypeBox’s internal kind keyword:

Error: strict mode: unknown keyword: "kind"

The fix is to use @fastify/type-provider-typebox and configure the instance properly:

const Fastify = require('fastify');
const { TypeBoxTypeProvider } = require('@fastify/type-provider-typebox');
const { Type } = require('@sinclair/typebox');

const fastify = Fastify().withTypeProvider(TypeBoxTypeProvider);

fastify.post('/users', {
  schema: {
    body: Type.Object({
      email: Type.String({ format: 'email' }),
      name: Type.String({ minLength: 1 })
    })
  }
}, async (request) => {
  return { created: request.body.email }; // Fully typed
});

Customize the validation error response:

By default, the error message includes Ajv’s raw validation output. To return a cleaner error:

fastify.setErrorHandler((error, request, reply) => {
  if (error.validation) {
    reply.status(400).send({
      statusCode: 400,
      error: 'Validation Error',
      details: error.validation.map(v => ({
        field: v.instancePath.replace('/', ''),
        message: v.message
      }))
    });
    return;
  }
  reply.send(error);
});

Fix 5: TypeScript — request.body Is unknown

Without a type provider or generic parameters, Fastify types request.body as unknown. You have two options:

Option 1: Generic interface (works without extra dependencies):

import Fastify, { FastifyRequest } from 'fastify';

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

const fastify = Fastify();

fastify.post<{ Body: CreateUserBody }>('/users', async (request) => {
  const { email, name } = request.body; // Typed correctly
  return { email };
});

Option 2: Type provider with TypeBox (recommended — types flow from schema):

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

const fastify = Fastify().withTypeProvider<TypeBoxTypeProvider>();

fastify.post('/users', {
  schema: {
    body: Type.Object({
      email: Type.String(),
      name: Type.String()
    }),
    params: Type.Object({ id: Type.String() }),
    querystring: Type.Object({ page: Type.Number() })
  }
}, async (request) => {
  // All of these are fully typed without manual interface definitions:
  request.body.email;        // string
  request.params.id;         // string
  request.query.page;        // number
});

Type custom decorators on FastifyRequest:

declare module 'fastify' {
  interface FastifyRequest {
    userId: string;
  }
}

fastify.decorateRequest('userId', '');

fastify.addHook('preHandler', async (request) => {
  request.userId = extractUserIdFromToken(request.headers.authorization);
});

fastify.get('/profile', async (request) => {
  return { userId: request.userId }; // Typed
});

Fix 6: CORS Not Working with @fastify/cors

Register the plugin before defining routes:

const fastify = require('fastify')();
const cors = require('@fastify/cors');

// Register BEFORE routes
await fastify.register(cors, {
  origin: ['https://app.example.com', 'https://example.com:3000'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
});

fastify.get('/api/data', handler);

The CORS setup is simpler than Express cors middleware, but the wildcard-with-credentials trap is the same. The main Fastify-specific difference is that you must await the register call before defining routes.

The three most common CORS mistakes in Fastify:

1. Wildcard with credentials:

// WRONG — browsers reject this combination
await fastify.register(cors, { origin: '*', credentials: true });

// CORRECT — specify exact origins when using credentials
await fastify.register(cors, {
  origin: ['https://app.example.com'],
  credentials: true
});

2. Port missing from allowed origin:

https://example.com and https://example.com:3000 are different origins. Include the port:

// WRONG if your frontend runs on port 3000
origin: 'https://example.com'

// CORRECT
origin: 'https://example.com:3000'

// Or match any port with a regex:
origin: /^https:\/\/example\.com(:\d+)?$/

3. Unlike Express, Fastify doesn’t handle OPTIONS automatically. The @fastify/cors plugin handles this for you — but only if it’s registered. If you’re seeing CORS errors on preflight (OPTIONS) requests, confirm the plugin is registered before the routes generating those errors.

Fix 7: Hooks Not Running

If your onRequest, preHandler, or other hooks aren’t executing for certain routes, the issue is almost always scope or registration order.

Hook registered after the route it should cover:

// WRONG — route is registered before the hook
fastify.get('/protected', protectedHandler);
fastify.addHook('preHandler', authHook); // Too late — doesn't apply to /protected
// CORRECT — hook registered first
fastify.addHook('preHandler', authHook);
fastify.get('/protected', protectedHandler); // Hook applies

Hook in wrong scope:

A hook registered inside a plugin only applies to routes in that plugin’s scope and its descendants. It does not apply to routes in sibling plugins or the parent scope:

// Hook registered in plugin A
fastify.register(async function pluginA(fastify) {
  fastify.addHook('preHandler', logHook);
  fastify.get('/a', handler); // logHook runs ✓
});

// Plugin B — logHook does NOT run here
fastify.register(async function pluginB(fastify) {
  fastify.get('/b', handler); // logHook doesn't apply ✗
});

To apply a hook globally, register it at the root scope before registering any plugins:

// Root scope — applies to all routes
fastify.addHook('preHandler', authHook);

fastify.register(pluginA); // authHook applies
fastify.register(pluginB); // authHook applies

Pro Tip: Don’t use arrow functions in hooks if you need access to this (the Fastify instance). Arrow functions lose the this binding:

// WRONG — this is undefined
fastify.addHook('preHandler', async (request, reply) => {
  this.myDecorator; // undefined
});

// CORRECT
fastify.addHook('preHandler', async function(request, reply) {
  this.myDecorator; // Works
});

Still Not Working?

Fastify v4 → v5 Breaking Changes

If you upgraded to Fastify v5 and existing code broke, check these specific changes:

request.connection removed (use request.socket):

// v4
const ip = request.connection.remoteAddress;

// v5
const ip = request.socket.remoteAddress;

reply.getResponseTime() removed (use reply.elapsedTime):

// v4
const ms = reply.getResponseTime();

// v5
const ms = reply.elapsedTime;

Custom loggers no longer accepted:

// v4 — worked
const fastify = Fastify({ logger: pino() });

// v5 — throws an error
// Pass logger: true (or configure pino separately)
const fastify = Fastify({ logger: true });

Node.js version requirement: Fastify v5 requires Node.js v20+. If you’re on Node.js 18 or earlier, either upgrade Node or stay on Fastify v4.

FST_ERR_DEC_ALREADY_PRESENT on Hot Reload

During development with hot reload tools, Fastify may try to register the same decorator twice as modules reload. The error:

FastifyError: The decorator 'myDecorator' has already been added!
    FST_ERR_DEC_ALREADY_PRESENT

This happens because the Fastify instance persists between hot reloads but the plugin re-registers. The fix is to check before decorating, or to properly dispose and recreate the Fastify instance on reload:

if (!fastify.hasDecorator('myDecorator')) {
  fastify.decorate('myDecorator', value);
}

Alternatively, use frameworks like Fastify’s fastify-cli which handle server lifecycle management correctly.

Route conflicts (method+path already defined)

Fastify throws at startup if two routes use the same method and path:

FST_ERR_DUP_ROUTES: Routes with the same method and URL already exist

Unlike Express, which silently uses the first matching route, Fastify fails fast. Check for duplicate GET /users, POST /data, etc. registrations across your plugins.

async/await with callback-style plugins

Fastify v5 requires consistent async usage. If you mix callback plugins with async ones, you may see routes registered in a callback plugin never appearing:

// WRONG in v5 — callback style
fastify.register(function plugin(fastify, opts, done) {
  fastify.get('/route', handler);
  done();
});

// CORRECT — async/await
fastify.register(async function plugin(fastify) {
  fastify.get('/route', handler);
});

For TypeScript errors where properties don’t exist on Fastify types, see TypeScript property does not exist on type. For more on async errors in Node.js generally, see node unhandled rejection crash — Fastify surfaces unhandled async errors as 500 responses, so tracking them is important for production.

If you’re deploying Fastify behind a reverse proxy, misconfigured proxy headers can cause CORS or IP detection issues. See nginx 502 bad gateway for common proxy configuration problems.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles