How to Migrate from Express.js to Fastify

How do I migrate from Express.js to Fastify?

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('message')
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. See Step 1 below.
│   └── 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 (Step 2)
├── 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 (Step 6)
│   └── 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

Decision Logic

Structured rules an agent can apply directly when advising a migration. Use the inputs_needed answers to select the path.

If production runs Node.js 18 or earlier

→ Do NOT start a Fastify v5 migration. Upgrade Node to 20+ first (or 22 LTS); Fastify v5 hard-requires Node 20+ and v4 reached EOL on 2025-06-30. [src1, src6]

If the codebase is small (<20 routes, <5 middleware)

→ Skip the @fastify/express bridge. Rewrite all routes directly to native Fastify and deploy as one batch — the bridge overhead is not worth it at this size. [src2, src3]

If the codebase is medium/large and feature development cannot freeze

→ Use the @fastify/express v4.0.6 bridge for incremental, route-by-route migration; the first PR only adds Fastify and wraps the Express app, each later PR moves a few routes. [src2, src5, src9]

If you must enable HTTP/2

→ Migrate every route to native Fastify before turning HTTP/2 on. @fastify/express does not support HTTP/2 because Express cannot use Node's core HTTP/2 module. [src4]

If the app uses express-validator, Joi, or celebrate for validation

→ Replace them with Fastify's built-in JSON Schema (Ajv) per route; there is no automated Joi-to-JSON-Schema converter, so budget rewrite time, and always include the top-level type property. [src1, src6]

If you need per-route request timeouts

→ Use Fastify v5.8.0+ first-class handler-level timeouts instead of porting Express timeout middleware. [src9]

If the app relies on Express-only middleware with no Fastify equivalent (e.g., Passport strategies)

→ Keep those routes running through @fastify/express and migrate them last; do not block the rest of the migration on them. [src2, src4]

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.8.5)

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]

npm install @fastify/cors @fastify/helmet @fastify/compress @fastify/cookie @fastify/session
// BEFORE: Express middleware stack
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');

const app = express();
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'));
// No need for body parser — Fastify parses JSON by default

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 to replace validation middleware

Replace express-validator, Joi, or celebrate with Fastify's built-in JSON Schema validation. Schemas are declared per-route and validated automatically before the handler runs. [src1, src2]

// BEFORE: Express with express-validator
const { body, validationResult } = require('express-validator');

app.post('/api/users', [
  body('email').isEmail(),
  body('name').isLength({ min: 2 }),
], (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
  // ... create user
});

// AFTER: Fastify with built-in JSON Schema
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 — no manual checks needed
  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. Schema-driven responses also boost serialization speed by ~2x. [src7]

6. Convert error handling

Replace Express's 4-argument error middleware with Fastify's setErrorHandler(). Fastify catches async errors automatically — no try/catch wrappers needed in route handlers. [src2, src4]

// BEFORE: Express error middleware
app.use((err, req, res, next) => {
  console.error(err.stack);
  if (err.name === 'ValidationError') {
    return res.status(400).json({ error: err.message });
  }
  res.status(err.statusCode || 500).json({ error: 'Internal server error' });
});

// AFTER: Fastify error handler
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 automatically 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
# Also remove the @fastify/express bridge
npm uninstall @fastify/express

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

Code Examples

JavaScript: Complete Express-to-Fastify server migration

Full script: javascript-complete-express-to-fastify-server-migr.js (44 lines)

// 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({
# ... (see full script)

TypeScript: Fastify route plugin with full type safety

Full script: typescript-fastify-route-plugin-with-full-type-saf.ts (76 lines)

// Input:  Express route file with TypeScript
// Output: Fastify plugin with typed schema, request, and reply
import { FastifyPluginAsync } from 'fastify';
interface UserParams {
  id: string;
# ... (see full script)

JavaScript: Hooks as middleware replacement

Full script: javascript-hooks-as-middleware-replacement.js (34 lines)

// 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) {
# ... (see full script)

Anti-Patterns

Wrong: Using Express-style callback error handling

// BAD — wrapping every route handler in try/catch like Express
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 them 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 middleware globally like Express

// BAD — treating Fastify plugins like Express 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: Skipping JSON Schema validation

// BAD — manual validation like in 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;
});

// Also valid: explicit reply.send() without returning
fastify.get('/api/data', async (request, reply) => {
  const data = await fetchData();
  reply.send(data);
});

Wrong: Using old logger and listen() syntax from Fastify v4

// 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

# Check Fastify plugin compatibility
npx fastify-print-routes # prints all registered routes and their schemas

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

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

# Check Fastify server health and loaded plugins
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.8.5 (Apr 2026)CurrentFull JSON Schema required; listen() requires options object; reply.redirect(url, code) reversed args; loggerInstance replaces logger for custom loggers; request.connection removed (use request.socket); semicolon query delimiters off by default; parameters object loses prototype chainPatches CVE-2026-33806 (5.8.5, port parsing), CVE-2026-3635 (5.8.3), CVE-2026-3419 (5.8.1, content-type validation bypass), CVE-2026-25224 (5.7.3). v5.8.0 added first-class handler-level timeouts. Upgrade from v4: fix all deprecation warnings first, update schemas to include type [src9]
Fastify 5.7.x (Feb 2026)MaintenanceStricter RFC 9110 content-type header parsing (5.7.2) — non-standard headers may now be rejectedBump to 5.8.5 for the latest security patches [src9]
Fastify 5.0 (Sep 2024)Stable20+ breaking changes — see V5 Migration Guide. Minimum Node.js 20. Non-standard HTTP methods removedMajor release via OpenJS Foundation. Performance 5-10% faster than v4 [src8]
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 core (requires @fastify/express or @fastify/middie)Last version with built-in middleware support
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 strategies 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