Skip to main content

What is Middleware?

Middleware functions run code before and/or after route actions. They’re a powerful way to add functionality like logging, authentication, error handling, and data parsing to your routes.
import type { Middleware } from 'remix/fetch-router'

function logger(): Middleware {
  return async (context, next) => {
    let start = Date.now()
    
    // Call next() to invoke the next middleware or action
    let response = await next()
    
    let duration = Date.now() - start
    console.log(`${context.method} ${context.url.pathname} - ${response.status} (${duration}ms)`)
    
    return response
  }
}

Middleware Signature

The Middleware type defines the function signature:
interface Middleware<
  method extends RequestMethod | 'ANY' = RequestMethod | 'ANY',
  params extends Record<string, any> = {},
> {
  (
    context: RequestContext<params>,
    next: NextFunction,
  ): Response | undefined | void | Promise<Response | undefined | void>
}

type NextFunction = () => Promise<Response>
context
RequestContext<params>
required
The request context object containing request, url, params, method, headers, and context storage methods (get, set, has).
next
NextFunction
required
A function that invokes the next middleware or handler in the chain. Returns a Promise that resolves to the Response.
return
Response | undefined | void | Promise<...>
Middleware can:
  • Return a Response to short-circuit the chain
  • Call next() and return its result
  • Return nothing (implicitly calls next())

Global Middleware

Global middleware runs on every request before routes are matched:
import { createRouter } from 'remix/fetch-router'
import { logger } from 'remix/logger-middleware'
import { formData } from 'remix/form-data-middleware'

let router = createRouter({
  middleware: [
    logger(),
    formData(),
  ],
})
Global middleware is useful for:
  • Request logging
  • Parsing request bodies
  • Session management
  • Security headers
  • Compression
  • Static file serving
Keep global middleware lightweight since it runs on every request.

Route Middleware

Route middleware runs only for specific routes, after global middleware but before the action:
router.map(routes.admin.dashboard, {
  middleware: [requireAuth()],
  action() {
    return new Response('Admin Dashboard')
  },
})

Inline Middleware

You can also attach middleware directly to an action:
router.map(routes, {
  actions: {
    home() {
      return new Response('Home')
    },
    admin: {
      actions: {
        dashboard: {
          middleware: [requireAuth()],
          action() {
            return new Response('Dashboard')
          },
        },
      },
    },
  },
})

Controller Middleware

Apply middleware to all routes in a controller:
router.map(routes.admin, {
  // Runs on all admin routes
  middleware: [requireAuth()],
  actions: {
    dashboard() {
      return new Response('Dashboard')
    },
    users() {
      return new Response('Users')
    },
    settings: {
      // Additional middleware just for settings
      middleware: [requireSuperAdmin()],
      action() {
        return new Response('Settings')
      },
    },
  },
})
Middleware cascades: [requireAuth(), requireSuperAdmin()] runs for the settings route.

Writing Middleware

Basic Pattern

Middleware is typically a factory function that returns the middleware function:
function myMiddleware(options?: Options): Middleware {
  return async (context, next) => {
    // Before action
    console.log('Before')
    
    // Call next middleware/action
    let response = await next()
    
    // After action
    console.log('After')
    
    return response
  }
}

Short-Circuiting

Return a Response to skip downstream middleware and actions:
function requireAuth(): Middleware {
  return (context, next) => {
    let token = context.headers.get('Authorization')
    
    if (!token) {
      // Short-circuit: don't call next()
      return new Response('Unauthorized', { status: 401 })
    }
    
    // Continue to next middleware/action
    return next()
  }
}

Modifying Context

Use context.set() to store data for downstream middleware and actions:
import { createContextKey } from 'remix/fetch-router'
import { Session } from 'remix/session'

function auth(options?: AuthOptions): Middleware {
  let token = options?.token ?? 'secret'
  
  return async (context, next) => {
    let authHeader = context.headers.get('Authorization')
    
    if (authHeader !== `Bearer ${token}`) {
      return new Response('Unauthorized', { status: 401 })
    }
    
    // Store authenticated user in context
    let userKey = createContextKey<User>()
    context.set(userKey, { id: '123', name: 'Alice' })
    
    return next()
  }
}

// Access in actions
router.get('/profile', ({ get }) => {
  let user = get(userKey)
  return Response.json({ user })
})

Modifying Responses

Modify the response returned from downstream:
function addSecurityHeaders(): Middleware {
  return async (context, next) => {
    let response = await next()
    
    // Clone to modify headers
    response = new Response(response.body, response)
    response.headers.set('X-Content-Type-Options', 'nosniff')
    response.headers.set('X-Frame-Options', 'DENY')
    response.headers.set('X-XSS-Protection', '1; mode=block')
    
    return response
  }
}

Error Handling

Wrap next() in try/catch to handle errors:
function errorHandler(): Middleware {
  return async (context, next) => {
    try {
      return await next()
    } catch (error) {
      console.error('Error:', error)
      return new Response('Internal Server Error', { status: 500 })
    }
  }
}

Common Middleware Examples

Authentication

import { Session } from 'remix/session'

function requireAuth(): Middleware {
  return ({ get }, next) => {
    let session = get(Session)
    let username = session.get('username')
    
    if (!username) {
      return new Response('Unauthorized', { status: 401 })
    }
    
    return next()
  }
}

Request Logging

import { logger } from 'remix/logger-middleware'

// Simple logger
let router = createRouter({
  middleware: [logger()],
})

// Custom logger
function customLogger(): Middleware {
  return async (context, next) => {
    let start = Date.now()
    console.log(`--> ${context.method} ${context.url.pathname}`)
    
    let response = await next()
    
    let duration = Date.now() - start
    console.log(`<-- ${response.status} (${duration}ms)`)
    
    return response
  }
}

Form Data Parsing

import { formData } from 'remix/form-data-middleware'

let router = createRouter({
  middleware: [formData()],
})

router.post('/contact', ({ get }) => {
  let formData = get(FormData)
  let message = formData.get('message')
  return new Response(`Got: ${message}`)
})

Session Management

import { session } from 'remix/session-middleware'
import { createCookie } from 'remix/cookie'
import { createCookieSessionStorage } from 'remix/session/cookie-storage'
import { Session } from 'remix/session'

let sessionCookie = createCookie('__session', {
  secrets: ['s3cr3t'],
})

let sessionStorage = createCookieSessionStorage()

let router = createRouter({
  middleware: [session(sessionCookie, sessionStorage)],
})

router.post('/login', ({ get }) => {
  let session = get(Session)
  let formData = get(FormData)
  let username = formData.get('username')
  
  session.set('username', username)
  
  return new Response('Logged in')
})

CORS Headers

function cors(options?: CorsOptions): Middleware {
  let origin = options?.origin ?? '*'
  let methods = options?.methods ?? 'GET,POST,PUT,DELETE'
  
  return async (context, next) => {
    // Handle preflight
    if (context.method === 'OPTIONS') {
      return new Response(null, {
        status: 204,
        headers: {
          'Access-Control-Allow-Origin': origin,
          'Access-Control-Allow-Methods': methods,
          'Access-Control-Allow-Headers': 'Content-Type',
        },
      })
    }
    
    let response = await next()
    
    // Add CORS headers to response
    response = new Response(response.body, response)
    response.headers.set('Access-Control-Allow-Origin', origin)
    
    return response
  }
}

Rate Limiting

function rateLimit(options: RateLimitOptions): Middleware {
  let requests = new Map<string, number[]>()
  let { maxRequests = 100, windowMs = 60000 } = options
  
  return (context, next) => {
    let ip = context.headers.get('X-Forwarded-For') ?? 'unknown'
    let now = Date.now()
    let timestamps = requests.get(ip) ?? []
    
    // Remove old timestamps
    timestamps = timestamps.filter(t => now - t < windowMs)
    
    if (timestamps.length >= maxRequests) {
      return new Response('Too Many Requests', { status: 429 })
    }
    
    timestamps.push(now)
    requests.set(ip, timestamps)
    
    return next()
  }
}

Middleware Execution Order

Middleware runs in this order:
  1. Global middleware (defined in createRouter)
  2. Controller middleware (defined in parent controller)
  3. Route middleware (defined in action’s middleware array)
  4. Action (the actual route handler)
let router = createRouter({
  middleware: [a()], // 1. Runs first
})

router.map(routes.admin, {
  middleware: [b()], // 2. Runs second for all admin routes
  actions: {
    dashboard: {
      middleware: [c()], // 3. Runs third for dashboard only
      action() {
        // 4. Finally, the action runs
        return new Response('Dashboard')
      },
    },
  },
})
Each middleware can call next() to continue down the chain, or return a Response to short-circuit.

Async Middleware

Middleware functions can be async:
function fetchUser(): Middleware {
  return async (context, next) => {
    let userId = context.headers.get('X-User-ID')
    
    // Async operation
    let user = await db.users.findById(userId)
    
    let userKey = createContextKey<User>()
    context.set(userKey, user)
    
    return next()
  }
}

Testing Middleware

Test middleware in isolation:
import { describe, it } from 'node:test'
import { RequestContext } from 'remix/fetch-router'

describe('auth middleware', () => {
  it('blocks unauthorized requests', async () => {
    let middleware = requireAuth()
    let request = new Request('https://example.com/admin')
    let context = new RequestContext(request)
    
    let next = async () => new Response('Protected')
    let response = await middleware(context, next)
    
    assert.equal(response?.status, 401)
  })
  
  it('allows authorized requests', async () => {
    let middleware = requireAuth()
    let request = new Request('https://example.com/admin', {
      headers: { Authorization: 'Bearer secret' },
    })
    let context = new RequestContext(request)
    
    let next = async () => new Response('Protected')
    let response = await middleware(context, next)
    
    assert.equal(response?.status, 200)
  })
})

Next Steps

Controllers

Organize routes with nested controllers

Forms

Handle form submissions with middleware

Build docs developers (and LLMs) love