Skip to main content

How to Migrate from Express to Fastify

·PkgPulse Team

TL;DR

Migrating from Express to Fastify gives you 2-3x better throughput and first-class TypeScript types. The route handler API is similar enough that most migrations are mechanical. Key differences: Fastify uses plugins (not middleware), request/response objects have different APIs, and you get built-in JSON schema validation. Most Express apps migrate in a day or two; complex middleware chains may take longer.

Key Takeaways

  • 2-3x throughput improvement — Fastify handles ~77K req/sec vs Express's ~27K
  • Plugin system replaces middlewarefastify.register() instead of app.use()
  • Built-in JSON Schema validation — no separate validation library needed for basic cases
  • TypeScript-first — Fastify generics type request body, params, query, response
  • fastify-express for gradual migration — run Express middleware inside Fastify

Step 1: Install Fastify

npm install fastify
npm install -D @types/node  # If TypeScript

# Common Fastify plugins (replacing Express middleware):
npm install @fastify/cors          # cors
npm install @fastify/helmet        # helmet
npm install @fastify/rate-limit    # express-rate-limit
npm install @fastify/jwt           # jsonwebtoken wrapper
npm install @fastify/cookie        # cookie-parser
npm install @fastify/static        # express.static
npm install @fastify/multipart     # multer (file uploads)
npm install @fastify/sensible      # HTTP error helpers

Step 2: Route Handler Migration

// Express route syntax:
app.get('/users/:id', async (req, res) => {
  const { id } = req.params;
  const user = await db.user.findById(id);
  if (!user) return res.status(404).json({ error: 'Not found' });
  res.json(user);
});

app.post('/users', async (req, res) => {
  const { name, email } = req.body;
  const user = await db.user.create({ name, email });
  res.status(201).json(user);
});

// Fastify equivalent:
fastify.get('/users/:id', async (request, reply) => {
  const { id } = request.params as { id: string };
  const user = await db.user.findById(id);
  if (!user) return reply.code(404).send({ error: 'Not found' });
  return user;  // Just return — Fastify auto-serializes
});

fastify.post('/users', async (request, reply) => {
  const { name, email } = request.body as { name: string; email: string };
  const user = await db.user.create({ name, email });
  return reply.code(201).send(user);
});

TypeScript-Typed Routes (Fastify advantage)

// Fastify generics give you full type safety:
interface CreateUserBody {
  name: string;
  email: string;
}

interface UserParams {
  id: string;
}

interface UserResponse {
  id: string;
  name: string;
  email: string;
  createdAt: string;
}

fastify.post<{
  Body: CreateUserBody;
  Reply: UserResponse;
}>('/users', async (request, reply) => {
  const { name, email } = request.body;
  // ↑ TypeScript knows: { name: string; email: string }

  const user = await db.user.create({ name, email });
  return reply.code(201).send(user);
  // ↑ TypeScript validates: must match UserResponse shape
});

Step 3: Middleware → Plugin Migration

// Express middleware pattern:
app.use(cors());
app.use(helmet());
app.use(express.json());

// Middleware order matters — app.use() wraps all routes below it

// Fastify plugin pattern:
// 1. Register global plugins before routes
// 2. Plugins are isolated by scope (can register per-route-group)
const fastify = Fastify({ logger: true });

// Register core plugins
await fastify.register(import('@fastify/cors'), {
  origin: process.env.CORS_ORIGIN,
  credentials: true,
});
await fastify.register(import('@fastify/helmet'));
await fastify.register(import('@fastify/sensible'));  // Adds reply.notFound(), etc.

// Route-scoped plugins (only applies to routes in this scope)
await fastify.register(async (scope) => {
  await scope.register(import('@fastify/jwt'), {
    secret: process.env.JWT_SECRET!,
  });

  // These routes require JWT auth:
  scope.addHook('preHandler', scope.authenticate);

  scope.get('/me', async (request) => {
    return request.user;
  });
}, { prefix: '/api/v1' });

Step 4: Hooks (Replacing Middleware Logic)

// Express: use middleware for cross-cutting concerns
app.use((req, res, next) => {
  req.requestId = uuid();
  next();
});

// Fastify: use hooks
fastify.addHook('onRequest', async (request, reply) => {
  request.requestId = crypto.randomUUID();
});

// All Fastify hooks (in order of execution):
// 1. onRequest      — first, before parsing
// 2. preParsing     — before body parsing
// 3. preValidation  — before schema validation
// 4. preHandler     — before route handler (for auth, etc.)
// 5. handler        — your route function
// 6. preSerialization — before response serialization
// 7. onSend         — before sending response
// 8. onResponse     — after response sent
// 9. onError        — on unhandled error

Step 5: Error Handling

// Express error middleware:
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal Server Error' });
});

// Fastify error handler:
fastify.setErrorHandler(async (error, request, reply) => {
  fastify.log.error(error);

  // Fastify HTTPError (from @fastify/sensible)
  if (error.statusCode) {
    return reply.code(error.statusCode).send({ error: error.message });
  }

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

  return reply.code(500).send({ error: 'Internal Server Error' });
});

// In route handlers — throw to trigger the error handler:
fastify.get('/users/:id', async (request, reply) => {
  const user = await db.user.findById(request.params.id);
  if (!user) throw reply.notFound('User not found');  // @fastify/sensible
  return user;
});

Step 6: JSON Schema Validation (Fastify Bonus)

// Fastify validates request/response automatically with JSON Schema
// No need for Joi, Zod middleware layer, or manual validation

fastify.post('/users', {
  schema: {
    body: {
      type: 'object',
      required: ['name', 'email'],
      properties: {
        name: { type: 'string', minLength: 1, maxLength: 50 },
        email: { type: 'string', format: 'email' },
      },
    },
    response: {
      201: {
        type: 'object',
        properties: {
          id: { type: 'string' },
          name: { type: 'string' },
          email: { type: 'string' },
        },
      },
    },
  },
}, async (request, reply) => {
  // request.body is validated — name and email guaranteed to exist
  const user = await db.user.create(request.body);
  return reply.code(201).send(user);
});

// Or use Zod with @fastify/type-provider-zod:
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
fastify.setValidatorCompiler(validatorCompiler);
fastify.setSerializerCompiler(serializerCompiler);

Gradual Migration: Run Both

// @fastify/express — run Express middleware inside Fastify
// Use during incremental migration
import fastifyExpress from '@fastify/express';

fastify.register(fastifyExpress);

// Now you can use Express middleware in Fastify:
fastify.use(expressMiddleware());  // Temporary bridge

Compare Express and Fastify on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.