Web Engineering ·

Middleware Patterns in Modern Web Frameworks: Hono, Next.js, and Express Compared

A deep comparison of middleware architecture across Express, Hono, and Next.js for TypeScript engineers. Covers composition models, type safety, common patterns like auth and rate limiting, production performance, and a framework decision guide.

Middleware Patterns in Modern Web Frameworks: Hono, Next.js, and Express Compared

Middleware is one of those concepts every web framework implements, but each one implements it differently enough that porting patterns between frameworks is non-trivial. The abstraction looks the same on the surface: intercept the request, optionally transform it, pass it to the next handler, optionally transform the response. But the execution model, type system integration, and composability differ significantly across Express, Hono, and Next.js.

This article is a precise comparison of those differences. The goal is not to declare a winner but to give you a clear mental model for each so you can make deliberate decisions about composition, ordering, and testing.

The Core Abstraction

Before comparing implementations, it helps to be precise about what middleware actually is. In the most general form, middleware is a function that sits between a request and a handler. It can:

  • Short-circuit: return a response without calling next
  • Pass through: call next and return its result unmodified
  • Wrap: call next, then modify the response before returning it
  • Augment: attach data to the request context, then call next

Most frameworks layer middleware as a stack. The request enters the top of the stack, passes through each layer calling next, reaches the route handler, then unwinds back through the stack on the response path. This is important: middleware that needs to run after the handler (logging response times, setting response headers) must await next() and act on the result.

Express: Linear Chain, Loose Types

Express middleware has the signature (req, res, next) => void. The chain is linear: middleware is registered in the order you call app.use() or router.use(), and each piece calls next() to continue.

import express, { Request, Response, NextFunction } from 'express'

const app = express()

// Logging middleware
app.use((req: Request, res: Response, next: NextFunction) => {
  const start = Date.now()
  res.on('finish', () => {
    console.log(`${req.method} ${req.path} ${res.statusCode} ${Date.now() - start}ms`)
  })
  next()
})

// Auth middleware — augments req with a user property
app.use(async (req: Request, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.replace('Bearer ', '')
  if (!token) return res.status(401).json({ error: 'Unauthorized' })

  try {
    const user = await verifyToken(token)
    // TypeScript does not know about req.user without module augmentation
    ;(req as any).user = user
    next()
  } catch {
    res.status(401).json({ error: 'Invalid token' })
  }
})

The problem with (req as any).user = user appears immediately. You have set a property on the request object, but TypeScript has no record of it. Every downstream handler must cast or use module augmentation.

The standard fix is to augment the Request type:

// src/types/express.d.ts
import { User } from './user'

declare global {
  namespace Express {
    interface Request {
      user?: User
    }
  }
}

This works, but it is global. Every route in your application now sees req.user as potentially defined, whether or not the auth middleware actually ran for that route. There is no static guarantee that req.user is present in a handler that is only reachable through the auth middleware.

Error handling in Express uses a four-argument signature that is easy to forget:

// Error middleware MUST have exactly four parameters.
// Express detects this by arity — if you omit `next`, it stops working silently.
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err)
  res.status(500).json({ error: 'Internal server error' })
})

The production footgun here is that order is everything and there is no enforcement. If you register an error handler before your routes, it never fires for route errors. If you register it after, but register a catch-all route before it, route errors are swallowed by the catch-all. The framework does not validate your setup; it just runs what you registered.

Hono: Compose-Based, Context-Typed

Hono’s middleware signature is (c: Context, next: Next) => Promise<Response | void>. Instead of separate req and res objects, the Context object carries both, plus environment bindings and a typed variable bag for passing data between middleware.

The composition model is explicit. Hono uses a compose function internally, similar to Koa. Middleware can await next() and act on the result, which means wrapping the response is idiomatic rather than a workaround.

import { Hono } from 'hono'

const app = new Hono()

// Logging middleware — wraps the request lifecycle
app.use('*', async (c, next) => {
  const start = Date.now()
  await next()
  // Code here runs AFTER the handler returns
  const duration = Date.now() - start
  c.res.headers.set('X-Response-Time', `${duration}ms`)
  console.log(`${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`)
})

The key difference from Express: await next() suspends the middleware, runs everything downstream, then returns. You do not need res.on('finish') or any lifecycle hook. The middleware is just async code that wraps the handler call.

Typed Context Variables

Hono solves the req.user problem through typed context variables. You define the variable types at the router level, and every handler that uses that router gets compile-time guarantees about what is in the context.

import { Hono } from 'hono'

type User = {
  id: string
  email: string
  role: 'admin' | 'member' | 'viewer'
}

// Define what variables middleware will set
type Variables = {
  user: User
}

const protectedRouter = new Hono<{ Variables: Variables }>()

// Auth middleware sets the variable
protectedRouter.use('*', async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (!token) return c.json({ error: 'Unauthorized' }, 401)

  const user = await verifyToken(token)
  if (!user) return c.json({ error: 'Invalid token' }, 401)

  c.set('user', user)
  await next()
})

// Handler reads the variable — fully typed, no cast required
protectedRouter.get('/profile', (c) => {
  const user = c.get('user') // type: User, not User | undefined
  return c.json({ id: user.id, email: user.email })
})

c.get('user') returns User, not User | undefined, because the router type guarantees that Variables.user is always set before this handler can run. This is a structural guarantee: you wired the router type to require user in variables, your middleware sets it, and the compiler enforces the contract.

The tradeoff: this typing only works within the scope of the typed router. If you mount protectedRouter under a parent app, the parent routes do not automatically inherit the Variables type. You need to wire the generic types at each level where you need them.

CORS and Rate Limiting

Hono ships with built-in middleware for common patterns:

import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { rateLimiter } from 'hono-rate-limiter'

const app = new Hono()

app.use('/api/*', cors({
  origin: ['https://app.example.com'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
}))

// Rate limiting with a sliding window
app.use('/api/*', rateLimiter({
  windowMs: 60_000,
  limit: 100,
  keyGenerator: (c) => c.req.header('CF-Connecting-IP') ?? 'unknown',
}))

The keyGenerator function receives a typed Context, so you can read headers, URL parameters, or context variables to derive the rate limit key. Combining this with the auth middleware means you can key on c.get('user').id for per-user limits after authentication.

Next.js: Edge Middleware, Different Runtime

Next.js middleware runs at the network edge before the request reaches your application code. The runtime is not Node.js: it is a stripped-down V8 environment that excludes most Node.js APIs. The signature uses the Web Fetch API.

// middleware.ts (at the project root)
import { NextRequest, NextResponse } from 'next/server'

export function middleware(request: NextRequest): NextResponse {
  const token = request.cookies.get('session')?.value

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // NextResponse.next() continues the request
  // You can modify request headers before they reach the handler
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-user-id', extractUserId(token))

  return NextResponse.next({
    request: { headers: requestHeaders },
  })
}

// Control which paths run this middleware
export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}

The matcher config is important. Next.js middleware runs on every request that matches the pattern. Running on all paths including static assets is wasteful and adds latency. Be explicit about what you protect.

Passing Data to Route Handlers

The edge runtime does not share memory with your route handlers. Middleware cannot set a variable that a route handler reads. The communication channel is request headers. Your middleware encodes data into request headers; your route handler reads those headers.

// middleware.ts
export function middleware(request: NextRequest) {
  const sessionToken = request.cookies.get('session')?.value
  const session = decodeSessionToken(sessionToken)

  const headers = new Headers(request.headers)
  headers.set('x-user-id', session.userId)
  headers.set('x-user-role', session.role)

  return NextResponse.next({ request: { headers } })
}
// app/api/profile/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  // Read what middleware set
  const userId = request.headers.get('x-user-id')
  const role = request.headers.get('x-user-role')

  // These are strings, not typed User objects
  // You are responsible for validation here
  if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const user = await db.users.findById(userId)
  return NextResponse.json(user)
}

This is a meaningful constraint. The header-based communication channel means:

  • Only string data can pass from middleware to handlers
  • You cannot pass complex objects or validated types
  • The route handler must re-validate or trust the header value

The practical implication: use Next.js edge middleware for routing decisions (redirect unauthenticated users, block by geography, A/B routing) rather than for enriching the request with typed data. Heavy validation and data fetching belong in the route handler or a shared utility, not in middleware.

Composing Multiple Behaviors

Next.js has one middleware file. There is no app.use() to chain middleware. If you need to apply different logic to different route groups, you implement that logic within a single middleware function:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'

export function middleware(request: NextRequest) {
  const path = request.nextUrl.pathname

  // Auth check for protected routes
  if (path.startsWith('/dashboard') || path.startsWith('/api/protected')) {
    const session = request.cookies.get('session')?.value
    if (!session || !isValidSession(session)) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }

  // Rate limit headers for API routes
  if (path.startsWith('/api/')) {
    const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown'
    const allowed = checkRateLimit(ip) // in-memory or KV-backed
    if (!allowed) {
      return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
    }
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

This is manageable for a handful of concerns but becomes a monolith quickly. Teams typically extract sections into separate functions (handleAuth, handleRateLimit) and call them from the main middleware. The single-file constraint is a trade-off for the simplicity of edge deployment.

Comparison: Composition Models and Type Safety

DimensionExpressHonoNext.js
Composition modelLinear, registration orderCompose, async/await wrappingSingle file, manual branching
Context typingModule augmentation (global)Generic Variables (scoped)Request headers (strings only)
Response wrappingres.on('finish') eventawait next() returnsNot applicable
Error handling4-arg middleware, order-sensitiveapp.onError()try/catch in handlers
RuntimeNode.jsNode.js, Workers, Deno, BunEdge (V8, no Node.js APIs)
Middleware scopeGlobal or router-scopedRouter-scoped with typed contextPath matcher patterns

Testing Middleware in Isolation

Testing middleware independently from your application matters. A middleware that is only testable when mounted in a full app is fragile and slow.

Express middleware can be tested by constructing mock req/res objects, though this creates distance from production behavior. A cleaner approach uses a small Express app in the test:

import express from 'express'
import request from 'supertest'
import { authMiddleware } from './auth'

describe('authMiddleware', () => {
  const app = express()
  app.use(authMiddleware)
  app.get('/test', (req, res) => res.json({ user: (req as any).user }))

  it('rejects requests without a token', async () => {
    const res = await request(app).get('/test')
    expect(res.status).toBe(401)
  })

  it('sets req.user on valid token', async () => {
    const token = signToken({ id: 'user-1', email: 'alice@example.com' })
    const res = await request(app)
      .get('/test')
      .set('Authorization', `Bearer ${token}`)
    expect(res.status).toBe(200)
    expect(res.body.user.id).toBe('user-1')
  })
})

Hono’s app.request() method makes this cleaner. No HTTP server, no port binding, no cleanup:

import { Hono } from 'hono'
import { describe, it, expect } from 'vitest'
import { authMiddleware } from './auth'

describe('authMiddleware', () => {
  const app = new Hono<{ Variables: { user: User } }>()
  app.use('*', authMiddleware)
  app.get('/test', (c) => c.json({ user: c.get('user') }))

  it('rejects requests without a token', async () => {
    const res = await app.request('/test')
    expect(res.status).toBe(401)
  })

  it('sets user in context on valid token', async () => {
    const token = signToken({ id: 'user-1', email: 'alice@example.com' })
    const res = await app.request('/test', {
      headers: { Authorization: `Bearer ${token}` },
    })
    expect(res.status).toBe(200)
    const body = await res.json()
    expect(body.user.id).toBe('user-1')
  })
})

For Next.js middleware, testing requires mocking NextRequest and asserting on the returned NextResponse. The edge runtime constraint means you cannot use Node.js test runners directly without a compatibility layer:

import { NextRequest } from 'next/server'
import { middleware } from './middleware'

describe('middleware', () => {
  it('redirects unauthenticated requests', () => {
    const request = new NextRequest('https://example.com/dashboard')
    // No session cookie set
    const response = middleware(request)
    expect(response.status).toBe(307)
    expect(response.headers.get('location')).toContain('/login')
  })

  it('passes through authenticated requests', () => {
    const request = new NextRequest('https://example.com/dashboard')
    request.cookies.set('session', validSessionToken())
    const response = middleware(request)
    expect(response.status).toBe(200) // NextResponse.next() returns 200
  })
})

Production Considerations

Performance overhead. Every middleware call adds overhead. In Express, the overhead is the function call plus the prototype chain lookup on req/res. In Hono, each await next() in the chain is a micro-task. In practice, for I/O-bound applications the difference is negligible: your database call takes 5ms and the middleware chain takes 0.05ms. Profile before optimizing.

The real performance concern is middleware that does unnecessary work. An auth middleware that verifies a JWT on every request to static assets, or a logging middleware that allocates a large string for requests that return 304, is worth fixing. Use path matching to scope middleware precisely.

Middleware vs route-level logic. A useful rule: middleware belongs to concerns that apply uniformly across a surface (all authenticated routes, all API routes, all routes from a specific origin). Logic that is specific to one endpoint belongs in the handler. Putting per-endpoint authorization in global middleware means your middleware needs to know about every route’s rules, which is coupling in the wrong direction.

An auth middleware that verifies “is the user authenticated” is correct as middleware. An authorization check that verifies “does this user have permission to delete this specific resource” belongs in the route handler where you have the resource ID and can query the permission model.

Ordering bugs. In Express, incorrect middleware order causes subtle bugs. Body parsing middleware must run before any middleware that reads the body. CORS headers must be set before any middleware that might return early (like rate limiters). A rate limiter that returns 429 before CORS headers are set will cause browsers to display CORS errors instead of 429 errors.

Hono and Next.js have the same ordering requirement but their composition models make it more explicit. In Hono, middleware registered with app.use() runs in order, and wrapping with await next() gives you control over the response path. In Next.js, the single-function model means you control the sequence explicitly in code.

Decision Framework

Use Express middleware patterns when you are maintaining an existing Express application, integrating with a large ecosystem of Express-specific middleware (passport.js, express-rate-limit, session libraries), or running on Node.js with no plans to change runtimes.

Use Hono’s middleware model when you want typed context variables, need the same middleware to run on Workers and Node.js, or want the await next() wrapping pattern without ceremony. Hono’s model is Koa-inspired but adds the type system integration that Koa never had.

Use Next.js edge middleware when your concern is a routing decision: auth redirects, A/B routing, geo-blocking, or header manipulation before the request reaches your application server. Do not use it for typed data enrichment or complex business logic. The edge runtime constraint (no Node.js APIs, string-only communication to handlers) reflects its intended purpose: fast, simple gatekeeping at the network edge.

The pattern that causes the most production pain is putting too much logic in edge middleware. Teams reach for it because “it runs before everything” without accounting for the constraints. An auth check that needs to query a database, parse a JWT with a complex key, or set typed user data on the request context is not a good fit for edge middleware. Push that logic to your route handlers or an API layer running on a full Node.js runtime.

If you are starting a new TypeScript project without an existing framework constraint, Hono’s composition model is the most ergonomic of the three. The typed context variables eliminate the main source of type unsafety in middleware-heavy applications, and the await next() wrapping pattern maps cleanly to the mental model of middleware as a stack.

For applications that span both the Next.js app router and custom API routes, you will likely use both: Next.js edge middleware for routing decisions, and a library like Hono mounted under /api/* for the API layer with its own typed middleware chain. These are not mutually exclusive.

The fundamentals matter regardless of which framework you choose: scope your middleware to the paths that need it, order it deliberately, keep business logic in handlers, and test each piece in isolation. The framework shapes the mechanics; the patterns are yours to get right.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.