How to Migrate from Express.js to Fastify

Type: Software Reference Confidence: 0.90 Sources: 8 Verified: 2026-02-23 Freshness: quarterly

TL;DR

Constraints

Quick Reference

Express PatternFastify EquivalentExample
app.get('/path', handler)fastify.get('/path', handler)fastify.get('/users', async (request, reply) => { return users })
(req, res, next) signature(request, reply) signatureasync (request, reply) => { return data }
res.json(data)return data (auto-serialized)return { id: 1, name: 'Alice' }
res.status(404).json(err)reply.code(404).send(err)reply.code(404).send({ error: 'Not found' })
app.use(middleware)fastify.register(plugin)fastify.register(require('@fastify/cors'))
express.Router()Plugin with prefixfastify.register(userRoutes, { prefix: '/api/users' })
express.static('public')@fastify/static pluginfastify.register(require('@fastify/static'), { root: path.join(__dirname, 'public') })
express.json() body parserBuilt-in (automatic)JSON parsing is on by default, no middleware needed
app.use((err, req, res, next) => {})fastify.setErrorHandler()fastify.setErrorHandler((error, request, reply) => { reply.code(500).send({ error }) })
express-validator / JoiBuilt-in JSON Schemafastify.post('/users', { schema: { body: userSchema } }, handler)
morgan / winston loggingBuilt-in Pino loggerconst fastify = Fastify({ logger: true }) then request.log.info('msg')
helmet middleware@fastify/helmet pluginfastify.register(require('@fastify/helmet'))
cors middleware@fastify/cors pluginfastify.register(require('@fastify/cors'), { origin: '*' })
compression middleware@fastify/compress pluginfastify.register(require('@fastify/compress'))
app.listen(3000, callback)await fastify.listen({ port: 3000 })await fastify.listen({ port: 3000, host: '0.0.0.0' })
req.connectionrequest.socketrequest.socket.remoteAddress (v5: request.connection removed)
res.redirect(302, url)reply.redirect(url, 302)reply.redirect('/new-path', 301) (v5: reversed args)
Custom logger: {logger: pinoInstance}{loggerInstance: pinoInstance}Fastify({ loggerInstance: pino() }) (v5: logger no longer accepts instances)

Decision Tree

START
├── Is this a greenfield project or adding Fastify to an existing Express app?
│   ├── GREENFIELD → Start fresh with Fastify, skip the bridge
│   └── EXISTING EXPRESS APP ↓
├── How large is the Express codebase?
│   ├── SMALL (<20 routes, <5 middleware) → Rewrite all routes directly, no bridge needed
│   └── MEDIUM/LARGE ↓
├── Can you freeze feature development during migration?
│   ├── YES → Rewrite route-by-route without the bridge, deploy as a batch
│   └── NO → Use @fastify/express bridge for incremental migration
├── Do you use Express-specific middleware with no Fastify equivalent?
│   ├── YES → Keep them running via @fastify/express, migrate last
│   └── NO ↓
├── Do you use custom Express error middleware (4-arg functions)?
│   ├── YES → Convert to fastify.setErrorHandler() early
│   └── NO ↓
├── Are you targeting HTTP/2?
│   ├── YES → Cannot use @fastify/express bridge; migrate all routes to native Fastify first
│   └── NO ↓
└── DEFAULT → Migrate routes one-by-one, replace middleware with @fastify/* plugins, add JSON Schema validation, remove Express when done

Step-by-Step Guide

1. Install Fastify alongside Express

Add Fastify and the Express compatibility bridge to your existing project. Both frameworks can coexist on the same server during migration. [src2, src4]

npm install fastify @fastify/express

Verify: node -e "const f = require('fastify'); console.log(f().version)" → prints Fastify version (e.g., 5.7.4)

2. Wrap Express app with Fastify bridge

Create a Fastify instance that proxies all requests to your existing Express app. This lets Fastify handle the server while Express routes still work. [src4, src5]

// server.js — bridge setup
const Fastify = require('fastify');
const expressApp = require('./app'); // your existing Express app

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

async function start() {
  // Register the Express compatibility layer
  await fastify.register(require('@fastify/express'));

  // Mount your entire Express app under Fastify
  fastify.use(expressApp);

  await fastify.listen({ port: 3000, host: '0.0.0.0' });
  console.log(`Server running on ${fastify.server.address().port}`);
}

start();

Verify: curl http://localhost:3000/your-existing-route → same response as before. All Express routes still work.

3. Migrate routes from Express to native Fastify

Convert Express route handlers one at a time. Change (req, res) to (request, reply) and return data instead of calling res.json(). [src2, src3]

// BEFORE: Express route
app.get('/api/users/:id', async (req, res) => {
  try {
    const user = await db.findUser(req.params.id);
    if (!user) return res.status(404).json({ error: 'Not found' });
    res.json(user);
  } catch (err) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

// AFTER: Fastify route (register as a plugin)
async function userRoutes(fastify, options) {
  fastify.get('/api/users/:id', {
    schema: {
      params: {
        type: 'object',
        properties: { id: { type: 'string' } },
        required: ['id']
      }
    }
  }, async (request, reply) => {
    const user = await db.findUser(request.params.id);
    if (!user) {
      reply.code(404);
      return { error: 'Not found' };
    }
    return user; // auto-serialized to JSON
  });
}

fastify.register(userRoutes);

Verify: curl http://localhost:3000/api/users/123 → same JSON response. Remove the Express version of the route.

4. Replace Express middleware with Fastify plugins

Convert common middleware to their Fastify equivalents. Each Express app.use() becomes a fastify.register() call. [src2, src4]

// BEFORE: Express middleware stack
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
app.use(cors({ origin: '*' }));
app.use(helmet());
app.use(compression());
app.use(express.json());

// AFTER: Fastify plugin registration
const Fastify = require('fastify');
const fastify = Fastify({ logger: true }); // JSON body parsing is built-in
fastify.register(require('@fastify/cors'), { origin: '*' });
fastify.register(require('@fastify/helmet'));
fastify.register(require('@fastify/compress'));

Verify: Check response headers for CORS, security headers, and compression. curl -v http://localhost:3000/api/users should show the same headers.

5. Add JSON Schema validation

Replace express-validator, Joi, or celebrate with Fastify's built-in JSON Schema validation. [src1, src2]

fastify.post('/api/users', {
  schema: {
    body: {
      type: 'object',
      required: ['email', 'name'],
      properties: {
        email: { type: 'string', format: 'email' },
        name: { type: 'string', minLength: 2 }
      }
    },
    response: {
      201: {
        type: 'object',
        properties: {
          id: { type: 'string' },
          email: { type: 'string' },
          name: { type: 'string' }
        }
      }
    }
  }
}, async (request, reply) => {
  // request.body is already validated
  const user = await db.createUser(request.body);
  reply.code(201);
  return user;
});

Verify: curl -X POST -H 'Content-Type: application/json' -d '{"email":"bad"}' http://localhost:3000/api/users → 400 with validation error.

6. Convert error handling

Replace Express's 4-argument error middleware with Fastify's setErrorHandler(). Fastify catches async errors automatically. [src2, src4]

fastify.setErrorHandler((error, request, reply) => {
  request.log.error(error);

  if (error.validation) {
    return reply.code(400).send({
      error: 'Validation Error',
      details: error.validation
    });
  }

  const statusCode = error.statusCode || 500;
  reply.code(statusCode).send({
    error: statusCode >= 500 ? 'Internal server error' : error.message
  });
});

Verify: Throw an error in any route handler — Fastify catches it and passes it to setErrorHandler(). No next(err) pattern needed.

7. Remove Express and clean up

Once all routes, middleware, and error handling are migrated, uninstall Express and related packages. [src5]

npm uninstall express cors helmet compression morgan express-validator body-parser
npm uninstall @fastify/express

Verify: grep -rn "require('express')" --include='*.js' --include='*.ts' → zero results. npm ls express → not found.

Code Examples

JavaScript: Complete Express-to-Fastify server migration

// Input:  An Express server with routes, middleware, and error handling
// Output: Equivalent Fastify server with plugins and JSON Schema validation

const Fastify = require('fastify');

// Create Fastify instance (replaces const app = express())
const fastify = Fastify({
  logger: true,           // Built-in Pino logger (replaces morgan/winston)
  ajv: {
    customOptions: {
      removeAdditional: true,  // Strip unknown properties
      coerceTypes: true        // Auto-coerce query string types
    }
  }
});

// Register plugins (replaces app.use(middleware))
fastify.register(require('@fastify/cors'), { origin: true });
fastify.register(require('@fastify/helmet'));
fastify.register(require('@fastify/compress'));

// Register route plugins with prefix (replaces express.Router)
fastify.register(require('./routes/users'), { prefix: '/api/users' });
fastify.register(require('./routes/products'), { prefix: '/api/products' });

// Global error handler (replaces app.use((err, req, res, next) => {}))
fastify.setErrorHandler((error, request, reply) => {
  request.log.error({ err: error }, 'Request error');
  if (error.validation) {
    return reply.code(400).send({ error: 'Bad Request', details: error.validation });
  }
  reply.code(error.statusCode || 500).send({
    error: error.statusCode >= 500 ? 'Internal Server Error' : error.message
  });
});

// 404 handler (replaces app.use((req, res) => res.status(404)...))
fastify.setNotFoundHandler((request, reply) => {
  reply.code(404).send({ error: 'Route not found' });
});

// Start server (replaces app.listen(port, callback))
const start = async () => {
  try {
    await fastify.listen({ port: process.env.PORT || 3000, host: '0.0.0.0' });
  } catch (err) {
    fastify.log.fatal(err);
    process.exit(1);
  }
};

start();

TypeScript: Fastify route plugin with full type safety

// Input:  Express route file with TypeScript
// Output: Fastify plugin with typed schema, request, and reply

import { FastifyPluginAsync } from 'fastify';

interface UserParams { id: string; }
interface CreateUserBody { email: string; name: string; role?: 'admin' | 'user'; }
interface UserResponse { id: string; email: string; name: string; role: string; createdAt: string; }

const userRoutes: FastifyPluginAsync = async (fastify, opts) => {
  // GET /api/users/:id
  fastify.get<{ Params: UserParams; Reply: UserResponse }>(
    '/:id',
    {
      schema: {
        params: {
          type: 'object',
          properties: { id: { type: 'string', format: 'uuid' } },
          required: ['id']
        },
        response: {
          200: {
            type: 'object',
            properties: {
              id: { type: 'string' }, email: { type: 'string' },
              name: { type: 'string' }, role: { type: 'string' },
              createdAt: { type: 'string' }
            }
          }
        }
      }
    },
    async (request, reply) => {
      const user = await fastify.db.findUser(request.params.id);
      if (!user) { reply.code(404); return { error: 'User not found' }; }
      return user;
    }
  );

  // POST /api/users
  fastify.post<{ Body: CreateUserBody; Reply: UserResponse }>(
    '/',
    {
      schema: {
        body: {
          type: 'object',
          required: ['email', 'name'],
          properties: {
            email: { type: 'string', format: 'email' },
            name: { type: 'string', minLength: 2 },
            role: { type: 'string', enum: ['admin', 'user'], default: 'user' }
          }
        }
      }
    },
    async (request, reply) => {
      const user = await fastify.db.createUser(request.body);
      reply.code(201);
      return user;
    }
  );
};

export default userRoutes;

JavaScript: Hooks as middleware replacement

// Input:  Express middleware chain for auth + rate limiting
// Output: Fastify hooks and decorators achieving the same flow

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

// Authentication plugin (replaces app.use(authMiddleware))
const authPlugin = fp(async function (fastify, opts) {
  fastify.decorate('authenticate', async function (request, reply) {
    const token = request.headers.authorization?.replace('Bearer ', '');
    if (!token) {
      reply.code(401);
      throw new Error('Missing authentication token');
    }
    try {
      request.user = await fastify.jwt.verify(token);
    } catch (err) {
      reply.code(401);
      throw new Error('Invalid token');
    }
  });
});

// Register auth plugin globally
fastify.register(authPlugin);

// Apply auth to specific routes using preHandler hook
fastify.register(async function protectedRoutes(fastify) {
  fastify.addHook('preHandler', fastify.authenticate);
  fastify.get('/api/profile', async (request) => {
    return { user: request.user };
  });
  fastify.get('/api/settings', async (request) => {
    return { settings: await db.getSettings(request.user.id) };
  });
});

// Public routes — no auth hook applied
fastify.get('/api/health', async () => ({ status: 'ok' }));

Anti-Patterns

Wrong: Wrapping every handler in try/catch like Express

// ❌ BAD — unnecessary try/catch; Fastify handles async errors automatically
fastify.get('/api/users/:id', async (request, reply) => {
  try {
    const user = await db.findUser(request.params.id);
    if (!user) {
      reply.code(404).send({ error: 'Not found' });
      return;
    }
    reply.send(user);
  } catch (err) {
    reply.code(500).send({ error: 'Internal server error' });
  }
});

Correct: Let Fastify handle errors automatically

// ✅ GOOD — Fastify catches rejected promises and routes to setErrorHandler
fastify.get('/api/users/:id', async (request, reply) => {
  const user = await db.findUser(request.params.id);
  if (!user) {
    reply.code(404);
    return { error: 'Not found' };
  }
  return user; // auto-serialized, no reply.send() needed
});

Wrong: Registering auth middleware globally like Express

// ❌ BAD — treating plugins like Express global middleware
fastify.register(require('@fastify/cors'));
fastify.register(require('@fastify/auth'));
// Every route gets auth, even public ones
fastify.get('/api/health', async () => ({ status: 'ok' }));
fastify.get('/api/protected', async (request) => ({ user: request.user }));

Correct: Use plugin encapsulation for scoped middleware

// ✅ GOOD — encapsulate auth to only the routes that need it
fastify.register(require('@fastify/cors')); // global: fine

// Public routes
fastify.get('/api/health', async () => ({ status: 'ok' }));

// Protected routes in their own scope
fastify.register(async function (fastify) {
  fastify.addHook('preHandler', fastify.authenticate);
  fastify.get('/api/protected', async (request) => ({ user: request.user }));
});

Wrong: Manual validation instead of JSON Schema

// ❌ BAD — manual validation like Express, missing Fastify's core advantage
fastify.post('/api/users', async (request, reply) => {
  const { email, name } = request.body;
  if (!email || !email.includes('@')) {
    reply.code(400);
    return { error: 'Invalid email' };
  }
  if (!name || name.length < 2) {
    reply.code(400);
    return { error: 'Name too short' };
  }
  return await db.createUser({ email, name });
});

Correct: Declare JSON Schema for validation and serialization

// ✅ GOOD — schema validated before handler runs, response serialized faster
fastify.post('/api/users', {
  schema: {
    body: {
      type: 'object',
      required: ['email', 'name'],
      properties: {
        email: { type: 'string', format: 'email' },
        name: { type: 'string', minLength: 2 }
      }
    }
  }
}, async (request, reply) => {
  reply.code(201);
  return await db.createUser(request.body); // body is guaranteed valid
});

Wrong: Mixing reply.send() with return values

// ❌ BAD — calling reply.send() AND returning a value causes FST_ERR_PROMISE_NOT_FULFILLED
fastify.get('/api/data', async (request, reply) => {
  const data = await fetchData();
  reply.send(data);
  return data; // double response — Fastify throws an error
});

Correct: Use either return OR reply.send(), never both

// ✅ GOOD — return data for automatic serialization
fastify.get('/api/data', async (request, reply) => {
  const data = await fetchData();
  return data;
});

Wrong: Using deprecated Fastify v4 patterns in v5

// ❌ BAD — these patterns break in Fastify v5
const pino = require('pino')();
const fastify = Fastify({ logger: pino }); // v5: logger no longer accepts instances
fastify.listen(3000, '0.0.0.0', callback); // v5: variadic listen() removed
const time = reply.getResponseTime(); // v5: removed

Correct: Use v5 API for logger, listen, and response time

// ✅ GOOD — Fastify v5 correct syntax
const pino = require('pino')();
const fastify = Fastify({ loggerInstance: pino }); // v5: use loggerInstance
await fastify.listen({ port: 3000, host: '0.0.0.0' }); // v5: options object only
const time = reply.elapsedTime; // v5: replaces getResponseTime()

Common Pitfalls

Diagnostic Commands

# Verify Fastify version
node -e "console.log(require('fastify/package.json').version)"

# Check for remaining Express imports in the codebase
grep -rn "require('express')\|from 'express'" --include='*.js' --include='*.ts' --include='*.mjs'

# Verify no Express dependencies remain
npm ls express body-parser cors helmet morgan express-validator

# Print all registered Fastify routes and schemas
npx fastify-print-routes

# Test JSON Schema validation
curl -X POST -H 'Content-Type: application/json' -d '{"bad":"data"}' http://localhost:3000/api/users

# Benchmark before and after migration
npx autocannon -c 100 -d 10 http://localhost:3000/api/users

# Check Fastify server health
curl http://localhost:3000/health

# Verify Node.js version meets Fastify v5 minimum
node -e "const [major] = process.versions.node.split('.'); console.log(major >= 20 ? 'OK: Node ' + process.version : 'FAIL: Fastify v5 requires Node 20+');"

# Check for deprecated Fastify v4 patterns
grep -rn "getResponseTime\|reply\.redirect([0-9]\|logger:.*require('pino')" --include='*.js' --include='*.ts'

Version History & Compatibility

VersionStatusBreaking ChangesMigration Notes
Fastify 5.7.x (Feb 2026)CurrentFull JSON Schema required; listen() requires options object; reply.redirect(url, code) reversed args; loggerInstance replaces logger for custom loggers; request.connection removed; semicolon query delimiters off by defaultCVE-2026-25224 patched in 5.7.3. Upgrade from v4: fix all deprecation warnings first, update schemas to include type
Fastify 5.0 (Sep 2024)Stable20+ breaking changes. Minimum Node.js 20. Non-standard HTTP methods removedMajor release via OpenJS Foundation. Performance 5-10% faster than v4
Fastify 4.x (2022–2025)EOL (June 30, 2025)reply.send() no longer returns promise; content type parser changesWas LTS; most migration guides still reference v4 patterns
Fastify 3.x (2020–2022)EOLMiddleware removed from coreLast version with built-in middleware support; requires @fastify/express or @fastify/middie
Express 5.x (2024)Currentreq.hostreq.hostname; regex route changes; removed several deprecated methodsIf on Express 5, migration patterns to Fastify are similar
Express 4.x (2014–present)MaintenanceMinimal — stable for yearsSource framework for migration; no active feature development

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
API-heavy app needing >10K req/sec throughputSimple static file server with minimal routingExpress or serve-static
You want built-in validation, serialization, loggingRapid prototype where Express knowledge saves timeStay with Express
TypeScript-first codebase needing type-safe routesApp relies heavily on Express-only middleware (e.g., Passport without Fastify adapter)Express + specific middleware
Microservices architecture with plugin encapsulationTeam is unfamiliar with Fastify and has no time to learnKeep Express, optimize later
Need native OpenAPI/Swagger spec generationServerless functions (Cloudflare Workers, Vercel Edge)Hono or framework-agnostic handlers
Moving to Node.js 20+/22+ and want modern frameworkStuck on Node.js 18 or earlier in productionStay on Express or use Fastify v4 (EOL)

Important Caveats

Related Units