Back to Scaling Node.js Applications guides

Express Alternatives for Modern Node.js Web Development

Ayooluwa Isaiah
Updated on April 28, 2025

Express.js has long been the go-to framework for building web applications with Node.js. Since its initial release in 2010, it has established itself as the most popular lightweight web framework in the Node.js ecosystem. However, as web development practices evolve and new requirements emerge, developers are increasingly exploring alternatives that offer different architectural approaches, improved performance, or enhanced developer experience.

While Express.js remains an excellent choice for many projects, understanding the alternatives can help you make informed decisions based on your specific requirements. Whether you're looking for something more opinionated, more performance-focused, or simply want to explore the current landscape of Node.js frameworks, this article will guide you through the most compelling Express.js alternatives available today.

Why consider alternatives to Express.js?

Express.js has earned its place as the most widely used Node.js framework for several good reasons: it's minimalist, flexible, and has a massive ecosystem of middleware and extensions. However, there are legitimate reasons to explore alternatives:

  1. Minimalism vs. structure: Express.js is extremely lightweight and unopinionated, which gives you freedom but also means you need to make many architectural decisions yourself. Some developers prefer more structured frameworks.

  2. Modern JavaScript features: Express.js predates many modern JavaScript features and patterns. Newer frameworks are built with async/await, decorators, and TypeScript in mind from the ground up.

  3. Performance requirements: While Express is reasonably fast, some newer frameworks are designed specifically for maximum throughput and minimal overhead.

  4. Developer experience: Some newer frameworks offer improved developer experiences with features like hot reloading, better error messages, or integrated testing tools.

  5. Specific use cases: Some frameworks are designed for specific use cases like REST APIs, real-time applications, or microservices.

Let's explore the most notable alternatives to Express.js and what makes each unique.

1. Fastify

Fastify has gained significant traction as a high-performance alternative to Express.js. It was designed with speed in mind while maintaining plugin extensibility and developer-friendly features.

Key features of Fastify

  • Performance: Fastify is one of the fastest Node.js frameworks available, often benchmarking significantly faster than Express.js for many operations.
  • Schema-based: Built-in JSON Schema validation for requests and responses.
  • Plugin system: Modular architecture makes it easy to extend and maintain.
  • TypeScript support: First-class TypeScript support out of the box.
  • Logging: Integrated logging with Pino for high-performance logging.

Getting started with Fastify

 
// Install: npm install fastify
const fastify = require('fastify')({ logger: true });

// Define a route
fastify.get('/', async (request, reply) => {
  return { hello: 'world' };
});

// POST example with JSON schema validation
fastify.post('/user', {
  schema: {
    body: {
      type: 'object',
      required: ['username', 'email'],
      properties: {
        username: { type: 'string' },
        email: { type: 'string', format: 'email' }
      }
    },
    response: {
      200: {
        type: 'object',
        properties: {
          success: { type: 'boolean' }
        }
      }
    }
  }
}, async (request, reply) => {
  // The request body is automatically validated!
  const { username, email } = request.body;

  // Process user registration...

  return { success: true };
});

// Start server
const start = async () => {
  try {
    await fastify.listen({ port: 3000 });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};
start();

Middleware in Fastify

While Express.js uses a middleware pattern, Fastify uses a slightly different hook system:

 
// Global hooks
fastify.addHook('onRequest', async (request, reply) => {
  // Called when a request is received
  // Similar to middleware in Express
});

fastify.addHook('preHandler', async (request, reply) => {
  // Called just before the route handler
  // Good place for authentication
});

fastify.addHook('onResponse', async (request, reply) => {
  // Called after the response has been sent
  // Good for logging
});

// Route-specific hooks
fastify.get('/protected', {
  preHandler: async (request, reply) => {
    // This hook only applies to this route
    if (!request.headers.authorization) {
      reply.code(401).send({ error: 'Unauthorized' });
    }
  }
}, async (request, reply) => {
  return { data: 'protected content' };
});

Migrating from Express to Fastify

Fastify provides an Express compatibility plugin to ease migration:

 
// Install: npm install @fastify/express
const fastify = require('fastify')();
const expressPlugin = require('@fastify/express');

// Register the plugin
await fastify.register(expressPlugin);

// Now you can use Express middleware
fastify.use(require('cors')());
fastify.use(require('helmet')());

// And Express-style middleware functions
fastify.use((req, res, next) => {
  req.user = getUser(req);
  next();
});

Fastify is an excellent choice if performance is a priority for your application, or if you appreciate a more structured approach to validation with JSON Schema.

2. Koa

Koa was created by the same team behind Express.js as a more modern, lightweight alternative. It's often described as "next-generation Express" and focuses on being smaller, more expressive, and using modern JavaScript features like async/await.

Key features of Koa

  • Lightweight core: Even smaller than Express.js with a focus on middleware composition.
  • Modern JavaScript: Built from the ground up for async/await.
  • Cleaner error handling: Improved error handling with try/catch.
  • No built-in routing: Routing is handled via middleware packages.
  • Middleware context: Middleware functions share a context object for the entire request lifecycle.

Getting started with Koa

 
// Install: npm install koa koa-router
const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

// Middleware example
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

// Route definition
router.get('/', async (ctx) => {
  ctx.body = { message: 'Hello World' };
});

router.post('/users', async (ctx) => {
  // Access request body (requires koa-bodyparser middleware)
  const userData = ctx.request.body;
  // Process user data...
  ctx.status = 201;
  ctx.body = { success: true, id: 'new-user-id' };
});

// Use the router
app.use(router.routes());
app.use(router.allowedMethods());

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

Error handling in Koa

One of Koa's strongest features is its elegant error handling:

 
// Global error handler
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.status = err.status || 500;
    ctx.body = {
      message: err.message,
      error: process.env.NODE_ENV === 'development' ? err : {}
    };
    // Application-level error reporting
    ctx.app.emit('error', err, ctx);
  }
});

// Error emitter example
app.on('error', (err, ctx) => {
  console.error('Server error:', err, ctx.request.url);
  // Log to external service
});

// Route that throws an error
router.get('/error', async () => {
  throw new Error('This is a demo error');
});

// Custom error with status
router.get('/users/:id', async (ctx) => {
  const user = await findUser(ctx.params.id);
  if (!user) {
    const err = new Error('User not found');
    err.status = 404;
    throw err;
  }
  ctx.body = user;
});

Koa is an excellent choice if you want a more modern, streamlined version of Express that embraces async/await and offers improved error handling. It's particularly well-suited for developers who appreciate minimalism and want to compose their stack from small, focused packages.

3. NestJS

NestJS is a fully-featured, opinionated framework built with TypeScript. It draws inspiration from Angular and provides a structured architecture for building scalable server-side applications.

Key features of NestJS

  • TypeScript-first: Built for and with TypeScript from the ground up.
  • Decorators and dependency injection: Uses modern patterns for clean code organization.
  • Modular architecture: Enforces a structured, modular approach to application design.
  • Framework integration: Works with Express.js by default but can also use Fastify.
  • Full-featured: Includes solutions for testing, validation, database access, WebSockets, and more.
  • OpenAPI integration: Built-in Swagger documentation generation.

Getting started with NestJS

First, install the Nest CLI and create a new project:

 
npm install -g @nestjs/cli
nest new project-name

Here's a typical controller in NestJS:

 
// users.controller.ts
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { AuthGuard } from '../auth/auth.guard';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';

@ApiTags('users')
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @ApiOperation({ summary: 'Create a new user' })
  @ApiResponse({ status: 201, description: 'User created successfully' })
  @ApiResponse({ status: 400, description: 'Bad request' })
  async create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  @Get()
  @UseGuards(AuthGuard)
  @ApiOperation({ summary: 'Get all users' })
  async findAll() {
    return this.usersService.findAll();
  }

  @Get(':id')
  @UseGuards(AuthGuard)
  @ApiOperation({ summary: 'Get user by ID' })
  @ApiResponse({ status: 200, description: 'User found' })
  @ApiResponse({ status: 404, description: 'User not found' })
  async findOne(@Param('id') id: string) {
    return this.usersService.findOne(id);
  }
}

And a service with dependency injection:

 
// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

  async create(createUserDto: CreateUserDto): Promise<User> {
    const user = this.usersRepository.create(createUserDto);
    return await this.usersRepository.save(user);
  }

  async findAll(): Promise<User[]> {
    return await this.usersRepository.find();
  }

  async findOne(id: string): Promise<User> {
    const user = await this.usersRepository.findOne({ where: { id } });
    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }
    return user;
  }
}

Modules in NestJS

NestJS organizes the application into modules, which encapsulate related components:

 
// users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService], // Make UsersService available to other modules
})
export class UsersModule {}

NestJS is ideal for larger enterprise applications or teams that benefit from a consistent, structured architecture. It has a steeper learning curve compared to Express or Koa, but provides significant advantages for complex applications, especially when using TypeScript.

4. Hapi

Hapi is a rich framework focused on configuration rather than code and middleware. It was originally developed by Walmart Labs for handling Black Friday scale.

Key features of Hapi

  • Configuration-centric: Define your application through configuration objects.
  • Built-in validation: Integrated parameter validation with Joi.
  • Plugin system: Modular and extensible through plugins.
  • Security focus: Emphasizes secure defaults and has built-in security headers.
  • Testing tools: Built with testability in mind.

Getting started with Hapi

 
// Install: npm install @hapi/hapi @hapi/joi
const Hapi = require('@hapi/hapi');
const Joi = require('@hapi/joi');

const init = async () => {
  const server = Hapi.server({
    port: 3000,
    host: 'localhost'
  });

  // Define routes
  server.route({
    method: 'GET',
    path: '/',
    handler: (request, h) => {
      return { message: 'Hello World!' };
    }
  });

  // Route with parameter validation
  server.route({
    method: 'GET',
    path: '/users/{id}',
    options: {
      validate: {
        params: Joi.object({
          id: Joi.string().required().min(3)
        }),
        query: Joi.object({
          fields: Joi.string().optional()
        }),
        failAction: (request, h, err) => {
          throw err;
        }
      }
    },
    handler: async (request, h) => {
      const { id } = request.params;
      // Fetch user from database
      return { id, name: 'Sample User' };
    }
  });

  await server.start();
  console.log('Server running on %s', server.info.uri);
};

process.on('unhandledRejection', (err) => {
  console.log(err);
  process.exit(1);
});

init();

Authentication in Hapi

Hapi has a robust authentication framework:

 
const Hapi = require('@hapi/hapi');
const Basic = require('@hapi/basic');

const init = async () => {
  const server = Hapi.server({ port: 3000 });

  await server.register(Basic);

  server.auth.strategy('simple', 'basic', {
    validate: async (request, username, password, h) => {
      // In a real app, you would validate against a database
      const isValid = username === 'admin' && password === 'password';
      const credentials = { id: '1', name: 'Admin User' };

      return { isValid, credentials };
    }
  });

  server.route({
    method: 'GET',
    path: '/protected',
    options: {
      auth: 'simple',
      handler: (request, h) => {
        return {
          message: 'You accessed the protected route!',
          user: request.auth.credentials
        };
      }
    }
  });

  await server.start();
};

init();

Plugins in Hapi

Hapi's plugin system provides a clean way to encapsulate functionality:

 
// usersPlugin.js
const usersPlugin = {
  name: 'users',
  version: '1.0.0',
  register: async function(server, options) {
    // Add routes related to users
    server.route([
      {
        method: 'GET',
        path: '/users',
        handler: (request, h) => {
          return [{ id: 1, name: 'User 1' }, { id: 2, name: 'User 2' }];
        }
      },
      {
        method: 'GET',
        path: '/users/{id}',
        handler: (request, h) => {
          return { id: request.params.id, name: `User ${request.params.id}` };
        }
      }
    ]);

    // Add a utility method for other plugins to use
    server.method('users.findById', (id) => {
      // In a real app, query a database
      return { id, name: `User ${id}` };
    });
  }
};

// In your main server file:
await server.register(usersPlugin);

// Now you can use the server method elsewhere
const user = await server.methods.users.findById(1);

Hapi is a solid choice for projects where validation, security, and configuration-driven development are priorities. It's particularly well-suited for enterprise environments where explicit configuration is preferred over convention.

5. Restify

Restify focuses specifically on building REST API services. It strips away features not needed for APIs and optimizes for building services that are maintainable and observable.

Key features of Restify

  • API-focused: Designed specifically for building RESTful web services.
  • Performance: Optimized for high throughput.
  • Debugging tools: Built-in DTrace for performance analysis.
  • Content negotiation: Strong support for HTTP content negotiation.
  • Versioning: Built-in support for API versioning.

Getting started with Restify

 
// Install: npm install restify
const restify = require('restify');

const server = restify.createServer({
  name: 'my-api',
  version: '1.0.0'
});

// Middleware
server.use(restify.plugins.acceptParser(server.acceptable));
server.use(restify.plugins.queryParser());
server.use(restify.plugins.bodyParser());

// Routes
server.get('/', (req, res, next) => {
  res.send({ message: 'Welcome to the API' });
  return next();
});

// Route with parameters
server.get('/users/:id', (req, res, next) => {
  const userId = req.params.id;
  // Fetch user from database
  res.send({ id: userId, name: 'Example User' });
  return next();
});

// POST example
server.post('/users', (req, res, next) => {
  const newUser = req.body;
  // Save to database
  res.send(201, { id: 'new-id', ...newUser });
  return next();
});

server.listen(3000, () => {
  console.log('%s listening at %s', server.name, server.url);
});

Versioning in Restify

One of Restify's standout features is built-in API versioning:

 
const restify = require('restify');
const server = restify.createServer();

// Version 1 of the API
server.get({ path: '/api/users', version: '1.0.0' }, (req, res, next) => {
  res.send([
    { id: 1, username: 'user1' },
    { id: 2, username: 'user2' }
  ]);
  return next();
});

// Version 2 adds more fields
server.get({ path: '/api/users', version: '2.0.0' }, (req, res, next) => {
  res.send([
    { id: 1, username: 'user1', email: 'user1@example.com', active: true },
    { id: 2, username: 'user2', email: 'user2@example.com', active: false }
  ]);
  return next();
});

// Client specifies version via Accept header
// e.g., 'Accept: application/json; version=2.0.0'
server.listen(3000);

Error handling in Restify

Restify has standardized error objects for HTTP responses:

 
const restify = require('restify');
const errors = require('restify-errors');

const server = restify.createServer();

server.get('/users/:id', (req, res, next) => {
  const userId = req.params.id;

  // Example conditional
  if (userId === '999') {
    return next(new errors.NotFoundError('User not found'));
  }

  if (!req.headers.authorization) {
    return next(new errors.UnauthorizedError('Authentication required'));
  }

  // Normal response
  res.send({ id: userId, name: 'Example User' });
  return next();
});

// Global error handler
server.on('restifyError', (req, res, err, callback) => {
  // Log the error
  console.error(err);
  // Optionally modify the error response
  err.toJSON = function() {
    return {
      error: {
        name: err.name,
        message: err.message,
        code: err.statusCode
      }
    };
  };
  return callback();
});

server.listen(3000);

Restify is the ideal choice when you're building a pure REST API service and don't need view rendering or other web application features. Its focus on API-specific concerns makes it more specialized than Express but more optimized for this specific use case.

6. Polka

Polka is a micro web framework that presents itself as a faster, smaller alternative to Express. It supports a similar middleware pattern but with a fraction of the overhead.

Key features of Polka

  • Tiny footprint: Only about 1KB minified and gzipped.
  • Express-like API: Similar routing and middleware interfaces to Express.
  • Performance focused: Claims to be 33-46% faster than Express for basic operations.
  • Compatible: Works with Express middleware, although some may require small adaptations.

Getting started with Polka

 
// Install: npm install polka sirv compression
const polka = require('polka');
const serve = require('sirv')('public');
const compression = require('compression')();

// Create app
const app = polka();

// Middleware (runs on all routes)
app.use(compression);
app.use(serve);

// Routes
app.get('/', (req, res) => {
  res.end('Hello world!');
});

app.get('/users/:id', (req, res) => {
  res.end(`User: ${req.params.id}`);
});

app.listen(3000, err => {
  if (err) throw err;
  console.log(`> Running on localhost:3000`);
});

Custom middleware in Polka

 
const polka = require('polka');
const { json } = require('body-parser');

const app = polka();

// Parse JSON requests
app.use(json());

// Custom logging middleware
app.use((req, res, next) => {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  next();
});

// Authentication middleware
const authenticate = (req, res, next) => {
  const token = req.headers.authorization;
  if (!token || token !== 'valid-token') {
    res.statusCode = 401;
    res.end('Unauthorized');
    return;
  }
  // Add user info to request
  req.user = { id: 1, name: 'Authenticated User' };
  next();
};

// Public route
app.get('/', (req, res) => {
  res.end('Public route');
});

// Protected route with route-specific middleware
app.get('/protected', authenticate, (req, res) => {
  res.end(`Welcome, ${req.user.name}!`);
});

app.listen(3000);

Response handling in Polka

One difference from Express is that Polka doesn't extend the response object with helper methods:

 
const polka = require('polka');
const { json } = require('body-parser');

const app = polka();
app.use(json());

// Helper function for JSON responses
const send = (res, status, data) => {
  res.statusCode = status;
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify(data));
};

app.get('/api/users', (req, res) => {
  const users = [
    { id: 1, name: 'User One' },
    { id: 2, name: 'User Two' }
  ];

  send(res, 200, users);
});

app.post('/api/users', (req, res) => {
  // req.body available because of body-parser
  const newUser = {
    id: 3,
    ...req.body
  };

  send(res, 201, newUser);
});

app.listen(3000);

Polka is perfect for scenarios where you want Express-like simplicity but with even less overhead. It's particularly well-suited for microservices, serverless functions, or any environment where minimizing bundle size and startup time is crucial.

Migration strategy

Here's a practical approach to migrating from Express.js:

  • Start with a small service: Choose a non-critical service or endpoint to migrate first.

  • Abstract your route handlers: Make your business logic independent of the framework:

 
// Framework-agnostic business logic
const userService = {
  async getUsers() {
    // Database operations
    return [{ id: 1, name: 'User 1' }, { id: 2, name: 'User 2' }];
  },

  async getUserById(id) {
    // Database operations
    return { id, name: `User ${id}` };
  }
};

// Express implementation
app.get('/users', async (req, res) => {
  const users = await userService.getUsers();
  res.json(users);
});

// Can be easily adapted to Fastify, Koa, etc.
  • Identify middleware dependencies: Check which Express middleware you're using and find equivalents.

  • Incremental adoption: Consider running both frameworks side by side during migration:

 
// Express app for existing routes
const express = require('express');
const expressApp = express();

// Fastify app for new routes
const fastify = require('fastify')();

// Set up Express routes
expressApp.get('/legacy-route', (req, res) => {
  res.json({ legacy: true });
});

// Set up Fastify routes
fastify.get('/new-route', async (request, reply) => {
  return { new: true };
});

// Run both servers on different ports
expressApp.listen(3000);
fastify.listen({ port: 3001 });

// Or proxy between them with a gateway
  • Testing: Ensure comprehensive tests for the migrated endpoints to verify behavior.

Final thoughts

Express.js remains a solid choice for many Node.js applications, but exploring alternatives can lead to improvements in performance, developer experience, or architectural structure. Whether you prefer the minimal approach of Polka, the performance focus of Fastify, the modern feel of Koa, the structure of NestJS, the configuration approach of Hapi, or the API specialization of Restify, there's a framework that aligns with your project's unique requirements.

The key is to understand your specific needs—performance benchmarks, team familiarity, project size, and long-term maintenance considerations—and choose an alternative that addresses them. By considering the tradeoffs of each framework presented in this article, you'll be well-equipped to select the right Express.js alternative for your next Node.js project.

Author's avatar
Article by
Ayooluwa Isaiah
Ayo is a technical content manager at Better Stack. His passion is simplifying and communicating complex technical ideas effectively. His work was featured on several esteemed publications including LWN.net, Digital Ocean, and CSS-Tricks. When he's not writing or coding, he loves to travel, bike, and play tennis.
Got an article suggestion? Let us know
Next article
Valibot: The Complete Guide to Data Validation
Learn how to build a reusable data validation layer in JavaScript and TypeScript using Valibot, a fast and lightweight schema validation library. This step-by-step guide covers basic usage, custom rules, error handling, and TypeScript integration for type-safe validation.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github