Skip to content

Middleware

Middleware is a function that runs before (and optionally after) your route handler. Middleware is how you add cross-cutting behavior to your application --- logging, authentication, compression, database access, and more --- without repeating yourself in every handler.

How Middleware Works

A middleware function receives two arguments:

  1. context --- The request context object, which contains the request, URL, params, and a key-value store for passing data between middleware and handlers.
  2. next --- A function that calls the next middleware in the chain, or the route handler if there are no more middleware.
ts
async function myMiddleware(context, next) {
  // Code here runs BEFORE the handler
  console.log('Request:', context.request.method, context.url.pathname)

  let response = await next()

  // Code here runs AFTER the handler
  console.log('Response:', response.status)

  return response
}

The middleware function can:

  • Return nothing --- Call next() and let the chain continue.
  • Return a Response --- Short-circuit the chain and skip the handler entirely.
  • Modify the context --- Add data that downstream middleware and the handler can access.
  • Modify the response --- Intercept the response from next() and change it before returning.

The Middleware Function Signature

ts
type Middleware = (
  context: RequestContext,
  next: () => Promise<Response>,
) => Response | Promise<Response> | void | Promise<void>

If the middleware returns void (or undefined), Remix calls next() automatically. This means simple middleware that only adds data to the context can skip calling next():

ts
// This middleware adds data to the context and implicitly calls next()
function addRequestId(context, next) {
  context.set(RequestId, crypto.randomUUID())
  // No return, no next() call --- Remix handles it
}

Returning vs. not returning

If you need to modify the response, you must call next() yourself and return the result:

ts
async function addHeaders(context, next) {
  let response = await next()
  response.headers.set('X-Custom', 'value')
  return response
}

If you just need to add data to the context, you can omit the next() call --- Remix will call it for you.

Global Middleware

Global middleware runs on every request. You add it when creating the router:

ts
import { createRouter } from 'remix/fetch-router'
import { compression } from 'remix/compression-middleware'
import { logger } from 'remix/logger-middleware'
import { cors } from 'remix/cors-middleware'

let router = createRouter({
  middleware: [
    logger(),
    cors({ origin: 'https://example.com' }),
    compression(),
  ],
})

Global middleware runs in the order you provide it. In this example, logger() runs first, then cors(), then compression(), and finally the route handler.

Per-Route Middleware

You can also attach middleware to specific routes or route groups when calling router.map():

ts
import { requireAuth } from './middleware/auth.ts'
import { rateLimit } from './middleware/rate-limit.ts'

// Only this route group requires auth
router.map(routes.admin, {
  middleware: [requireAuth],
  actions: {
    index() { /* ... */ },
    // ...
  },
})

// Rate limiting on the API
router.map(routes.api.webhooks, {
  middleware: [rateLimit({ max: 100, windowMs: 60_000 })],
  handler({ request }) { /* ... */ },
})

Per-route middleware runs after global middleware and before the handler.

Middleware Ordering Matters

The order of middleware is significant. Each middleware in the chain can:

  • Access data set by earlier middleware
  • Modify the request before later middleware sees it
  • Wrap the response from later middleware

A common ordering:

ts
let router = createRouter({
  middleware: [
    logger(),              // 1. Log every request
    compression(),         // 2. Compress responses
    staticFiles('./public'), // 3. Serve static files (skips remaining middleware for static files)
    csrf(),                // 4. CSRF protection
    formData(),            // 5. Parse form data
    methodOverride(),      // 6. Support PUT/DELETE from forms (reads form data)
    session(cookie, storage), // 7. Load session
    auth({ schemes }),     // 8. Load auth state from session
  ],
})

formData before methodOverride

methodOverride() reads the _method field from the parsed form data, so it must come after formData(). If you reverse the order, method override will not work.

Type-Safe Context with createContextKey()

Middleware adds data to the request context. To do this in a type-safe way, you create context keys:

ts
import { createContextKey } from 'remix/fetch-router'

// Create a typed context key
export const RequestId = createContextKey<string>('RequestId')
export const CurrentUser = createContextKey<User | null>('CurrentUser')

Middleware sets values using context.set():

ts
function addRequestId(context, next) {
  context.set(RequestId, crypto.randomUUID())
}

Handlers (and other middleware) retrieve values using context.get():

ts
handler({ get }) {
  let requestId = get(RequestId)  // string
  let user = get(CurrentUser)     // User | null
}

TypeScript enforces that the value matches the key's type. You cannot accidentally set a number for a string key or read the wrong type.

Built-In Context Keys

Remix provides several built-in context keys used by its middleware:

KeyPackageSet ByType
Databaseremix/data-tableDatabase middlewareDatabase
Sessionremix/sessionSession middlewareSession
Authremix/auth-middlewareAuth middlewareAuthState
FormDataremix/form-data-middlewareForm data middlewareFormData

Built-In Middleware Overview

Remix ships with a rich set of middleware. Each one is a separate import, and you only include the ones you need.

compression()

ts
import { compression } from 'remix/compression-middleware'

Compresses response bodies using gzip or brotli based on the client's Accept-Encoding header. Automatically skips compression for small responses and already-compressed content types.

logger()

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

Logs each request and response, including method, URL, status code, and response time. Useful during development.

cors()

ts
import { cors } from 'remix/cors-middleware'

cors({
  origin: 'https://example.com',       // or ['https://a.com', 'https://b.com']
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type'],
  credentials: true,
})

Handles Cross-Origin Resource Sharing (CORS) by setting the appropriate response headers. Required when your API is accessed from a different domain than the one serving it.

csrf()

ts
import { csrf } from 'remix/csrf-middleware'

Protects against Cross-Site Request Forgery attacks by verifying that form submissions originate from your site. Works with the session middleware.

staticFiles()

ts
import { staticFiles } from 'remix/static-middleware'

staticFiles('./public')

Serves static files from a directory. Supports ETag-based caching and range requests. Requests that match a static file are handled immediately without running subsequent middleware.

session()

ts
import { session } from 'remix/session-middleware'

session(cookie, storage)

Loads and saves session data. Makes context.get(Session) available to handlers. See the Sessions & Cookies guide.

formData()

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

Parses application/x-www-form-urlencoded and multipart/form-data request bodies. Makes context.get(FormData) available to handlers. See the Forms & Mutations guide.

methodOverride()

ts
import { methodOverride } from 'remix/method-override-middleware'

Reads a _method hidden field from form data and changes the request method accordingly. Enables HTML forms to submit PUT and DELETE requests.

asyncContext()

ts
import { asyncContext } from 'remix/async-context-middleware'

Enables access to the request context from anywhere in the call stack using Node.js AsyncLocalStorage, without explicitly passing it through function arguments.

auth()

ts
import { auth, requireAuth } from 'remix/auth-middleware'

Loads authentication state from configured auth schemes. requireAuth() creates a middleware that redirects unauthenticated users. See the Sessions & Cookies guide for more.

cop()

ts
import { cop } from 'remix/cop-middleware'

Content Security Policy middleware. Sets Content-Security-Policy headers to control which resources the browser is allowed to load.

Writing Custom Middleware

Creating your own middleware is straightforward. Here are several common patterns.

Adding Data to Context

ts
import { createContextKey } from 'remix/fetch-router'
import type { Middleware } from 'remix/fetch-router'

// 1. Create a context key
export const Timing = createContextKey<{ start: number }>('Timing')

// 2. Write the middleware
export function timing(): Middleware {
  return (context, next) => {
    context.set(Timing, { start: Date.now() })
  }
}

// 3. Use in handlers
handler({ get }) {
  let timing = get(Timing)
  console.log(`Request started at ${timing.start}`)
}

Short-Circuiting the Chain

Middleware can return a Response to skip the rest of the chain:

ts
export function maintenance(): Middleware {
  return (context, next) => {
    if (process.env.MAINTENANCE_MODE === 'true') {
      return new Response('Site is under maintenance', {
        status: 503,
        headers: { 'Retry-After': '3600' },
      })
    }
    return next()
  }
}

Wrapping the Response

Middleware can modify the response before it is sent to the client:

ts
export function serverTiming(): Middleware {
  return async (context, next) => {
    let start = performance.now()
    let response = await next()
    let duration = performance.now() - start

    response.headers.set(
      'Server-Timing',
      `total;dur=${duration.toFixed(1)}`,
    )

    return response
  }
}

Error Handling

Middleware can catch errors from downstream middleware and handlers:

ts
export function errorBoundary(): Middleware {
  return async (context, next) => {
    try {
      return await next()
    } catch (error) {
      console.error('Unhandled error:', error)
      return new Response('Internal Server Error', { status: 500 })
    }
  }
}

Middleware Composition Patterns

Conditional Middleware

Run middleware only in certain environments:

ts
let middleware = []

if (process.env.NODE_ENV === 'development') {
  middleware.push(logger())
}

middleware.push(compression())
middleware.push(staticFiles('./public'))

let router = createRouter({ middleware })

Middleware Factories

Create middleware that accepts configuration:

ts
export function rateLimit(options: { max: number; windowMs: number }): Middleware {
  let requests = new Map<string, { count: number; resetAt: number }>()

  return (context, next) => {
    let ip = context.request.headers.get('x-forwarded-for') ?? 'unknown'
    let now = Date.now()

    let record = requests.get(ip)
    if (!record || now > record.resetAt) {
      record = { count: 0, resetAt: now + options.windowMs }
      requests.set(ip, record)
    }

    record.count++

    if (record.count > options.max) {
      return new Response('Too Many Requests', {
        status: 429,
        headers: { 'Retry-After': String(Math.ceil((record.resetAt - now) / 1000)) },
      })
    }

    return next()
  }
}

Combining Multiple Middleware

Group related middleware into a single function:

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

export function webMiddleware(cookie, storage, authSchemes) {
  return [
    formData(),
    methodOverride(),
    session(cookie, storage),
    auth({ schemes: authSchemes }),
  ]
}

// Usage
let router = createRouter({
  middleware: [
    logger(),
    compression(),
    staticFiles('./public'),
    ...webMiddleware(sessionCookie, sessionStorage, [sessionAuth]),
  ],
})

Role-Based Access Control

Build middleware that checks for specific roles:

ts
import { Auth } from 'remix/auth-middleware'

export function requireRole(role: string): Middleware {
  return (context, next) => {
    let authState = context.get(Auth)

    if (!authState.ok) {
      return new Response(null, {
        status: 302,
        headers: { Location: '/login' },
      })
    }

    if (authState.identity.role !== role) {
      return new Response('Forbidden', { status: 403 })
    }

    return next()
  }
}

// Usage
router.map(routes.admin, {
  middleware: [requireRole('admin')],
  actions: { /* ... */ },
})

Middleware Type Safety

When middleware adds data to the context, you can encode this in the type system so that handlers know what data is available:

ts
import type { Middleware } from 'remix/fetch-router'
import { createContextKey } from 'remix/fetch-router'
import { Database } from 'remix/data-table'

type SetDatabaseContextTransform = readonly [
  readonly [typeof Database, Database],
]

export function loadDatabase(): Middleware<
  'ANY',
  {},
  SetDatabaseContextTransform
> {
  return async (context, next) => {
    context.set(Database, db)
    return next()
  }
}

The third type parameter (SetDatabaseContextTransform) declares what the middleware adds to the context. This enables TypeScript to verify that handlers calling get(Database) are downstream of this middleware.

Released under the MIT License.