Skip to main content
Remix packages are designed to be single-purpose, replaceable, and composable. This means you can mix and match packages to build exactly what you need, without being locked into a monolithic framework.

What Makes a Package Composable?

A composable package has these characteristics:

Single Responsibility

Each package does one thing well. Clear boundaries make it easy to understand and use.

Standalone Utility

Every package is useful on its own, with no required dependencies on other Remix packages.

Standard Interfaces

Packages use web standard types (Request, Response, File, etc.) to integrate seamlessly.

Easy to Replace

Don’t like a package? Replace it with your own or a third-party alternative.

Middleware Composition

Middleware is Remix’s primary composition pattern. Middleware functions run before and/or after route actions:

Building a Middleware Stack

import { createRouter } from 'remix/fetch-router'
import { logger } from 'remix/logger-middleware'
import { staticFiles } from 'remix/static-middleware'
import { formData } from 'remix/form-data-middleware'
import { compression } from 'remix/compression-middleware'

// Compose middleware in order
let router = createRouter({
  middleware: [
    logger(),           // Log all requests
    compression(),      // Compress responses
    staticFiles('./public'), // Serve static files
    formData(),         // Parse form data
  ],
})
Middleware runs in the order specified. Each middleware can decide whether to call next() to continue the chain or return a Response early.

Writing Custom Middleware

Middleware is just a function that takes context and a next function:
import type { Middleware } from 'remix/fetch-router'

function timing(): Middleware {
  return async (context, next) => {
    let start = Date.now()
    
    // Call next middleware or action
    let response = await next()
    
    let duration = Date.now() - start
    
    // Clone response to add headers
    response = new Response(response.body, response)
    response.headers.set('X-Response-Time', `${duration}ms`)
    
    return response
  }
}

// Use it
let router = createRouter({
  middleware: [timing()],
})

Route-Level Middleware

Apply middleware to specific routes:
import { route } from 'remix/fetch-router/routes'

let routes = route({
  public: {
    home: '/',
    about: '/about',
  },
  admin: {
    dashboard: '/admin/dashboard',
    users: '/admin/users',
  },
})

// No auth required
router.map(routes.public, {
  actions: {
    home: () => new Response('Home'),
    about: () => new Response('About'),
  },
})

// Auth required for admin routes
router.map(routes.admin, {
  middleware: [requireAuth()],
  actions: {
    dashboard: () => new Response('Dashboard'),
    users: () => new Response('Users'),
  },
})

Package Composition Patterns

Pattern 1: Storage Abstraction

Compose storage backends:
import { createFileStorage } from 'remix/file-storage'
import { createFsBackend } from 'remix/file-storage/fs'

// Compose storage interface with filesystem backend
let storage = createFileStorage({
  backend: createFsBackend({ directory: './uploads' }),
})

// Store files
await storage.set('avatar.jpg', file)
let file = await storage.get('avatar.jpg')
The storage interface stays the same - only the backend changes. This makes it easy to swap implementations based on environment or requirements.

Pattern 2: Session Storage

Compose session storage with different backends:
import { createSession } from 'remix/session'
import { createCookieStorage } from 'remix/session/cookie-storage'
import { createRedisStorage } from 'remix/session-storage-redis'
import { createMemcacheStorage } from 'remix/session-storage-memcache'

// Cookie-based sessions (no external service)
let sessionStorage = createCookieStorage({
  cookie: {
    name: '_session',
    secrets: ['secret1'],
  },
})

// Or Redis-backed sessions
let sessionStorage = createRedisStorage({
  client: redisClient,
  cookie: { name: '_session' },
})

// Or Memcache-backed sessions  
let sessionStorage = createMemcacheStorage({
  client: memcacheClient,
  cookie: { name: '_session' },
})

// Usage is identical
let session = await sessionStorage.getSession(request.headers.get('Cookie'))
session.set('userId', '123')
let headers = { 'Set-Cookie': await sessionStorage.commitSession(session) }

Pattern 3: Data Table Adapters

Compose database adapters:
import { createTable } from 'remix/data-table'
import { createSqliteAdapter } from 'remix/data-table-sqlite'

let users = createTable('users', {
  columns: {
    id: { type: 'integer', primaryKey: true },
    name: { type: 'text' },
    email: { type: 'text' },
  },
  adapter: createSqliteAdapter({ filename: './db.sqlite' }),
})

await users.insert({ name: 'Alice', email: '[email protected]' })

Pattern 4: Nested Routers

Compose routers for different parts of your application:
import { createRouter } from 'remix/fetch-router'
import { route } from 'remix/fetch-router/routes'

// Main router
let mainRouter = createRouter({
  middleware: [logger(), staticFiles('./public')],
})

// API router (separate concerns)
let apiRouter = createRouter({
  middleware: [requireApiKey()],
})

let apiRoutes = route({
  users: '/api/users',
  posts: '/api/posts',
})

apiRouter.map(apiRoutes, {
  actions: {
    users: () => Response.json({ users: [] }),
    posts: () => Response.json({ posts: [] }),
  },
})

// Compose routers
mainRouter.get('/*', async ({ request }) => {
  // Try API router first
  if (request.url.includes('/api/')) {
    return await apiRouter.fetch(request)
  }
  
  return new Response('Not Found', { status: 404 })
})

Composing Multiple Packages

Here’s a real-world example combining multiple packages:
import * as http from 'node:http'
import { createRouter } from 'remix/fetch-router'
import { route, form } from 'remix/fetch-router/routes'
import { createRequestListener } from 'remix/node-fetch-server'
import { logger } from 'remix/logger-middleware'
import { formData } from 'remix/form-data-middleware'
import { staticFiles } from 'remix/static-middleware'
import { compression } from 'remix/compression-middleware'
import { session } from 'remix/session-middleware'
import { createCookieStorage } from 'remix/session/cookie-storage'
import { createHtmlResponse } from 'remix/response/html'
import { html } from 'remix/html-template'

// Setup session storage
let sessionStorage = createCookieStorage({
  cookie: {
    name: '_session',
    secrets: [process.env.SESSION_SECRET],
  },
})

// Create router with composed middleware
let router = createRouter({
  middleware: [
    logger(),
    compression(),
    staticFiles('./public'),
    session({ storage: sessionStorage }),
    formData(),
  ],
})

// Define routes
let routes = route({
  home: '/',
  contact: form('/contact'),
})

// Map routes to actions
router.map(routes, {
  actions: {
    home: ({ get }) => {
      let session = get('session')
      return createHtmlResponse(html`
        <h1>Welcome ${session.get('name') || 'Guest'}!</h1>
        <a href="${routes.contact.index.href()}">Contact</a>
      `)
    },
    contact: {
      actions: {
        index: () => {
          return createHtmlResponse(html`
            <form method="POST" action="${routes.contact.action.href()}">
              <input name="name" required />
              <input name="email" type="email" required />
              <textarea name="message" required></textarea>
              <button type="submit">Send</button>
            </form>
          `)
        },
        action: ({ get }) => {
          let formData = get(FormData)
          let session = get('session')
          
          // Save to session
          session.set('name', formData.get('name'))
          
          return createHtmlResponse(html`
            <h1>Thanks for your message!</h1>
            <a href="${routes.home.href()}">Home</a>
          `)
        },
      },
    },
  },
})

// Connect to Node.js
let server = http.createServer(createRequestListener(router.fetch))
server.listen(3000)
This example composes 10+ packages together: fetch-router, node-fetch-server, multiple middleware packages, session storage, response helpers, and HTML templating.

Replacing Components

Because packages are composable, you can replace any component:

Replace the Router

// Don't like fetch-router? Use your own
import { YourRouter } from './your-router.ts'
import { createRequestListener } from 'remix/node-fetch-server'

let router = new YourRouter()

// As long as it has a fetch() method, it works
let server = http.createServer(createRequestListener(router.fetch))

Replace Middleware

// Replace Remix middleware with your own
import { yourLogger } from './your-logger.ts'
import { yourStaticFiles } from './your-static-files.ts'

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

Replace Storage Backend

// Implement your own storage backend
import type { FileStorageBackend } from 'remix/file-storage'

class CustomBackend implements FileStorageBackend {
  async get(key: string): Promise<File | null> {
    // Your implementation
  }
  
  async set(key: string, value: File): Promise<void> {
    // Your implementation
  }
  
  async delete(key: string): Promise<void> {
    // Your implementation
  }
}

let storage = createFileStorage({
  backend: new CustomBackend(),
})

Testing Composed Systems

Composable packages are easy to test in isolation:
import * as assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { createRouter } from 'remix/fetch-router'
import { formData } from 'remix/form-data-middleware'

describe('contact form', () => {
  it('handles form submission', async () => {
    let router = createRouter({
      middleware: [formData()],
    })
    
    router.post('/contact', ({ get }) => {
      let form = get(FormData)
      return Response.json({
        name: form.get('name'),
        email: form.get('email'),
      })
    })
    
    let form = new FormData()
    form.append('name', 'Alice')
    form.append('email', '[email protected]')
    
    let response = await router.fetch('https://example.com/contact', {
      method: 'POST',
      body: form,
    })
    
    let data = await response.json()
    assert.equal(data.name, 'Alice')
    assert.equal(data.email, '[email protected]')
  })
})
Test individual packages or entire composed systems using the same standard fetch API.

Best Practices

Begin with a single package and add more as you need them. Don’t over-engineer upfront.
Each middleware should do one thing well. Avoid creating middleware that does too much.
When building abstractions, define clear interfaces that other implementations can satisfy.
Test each component independently before testing the composed system.
When packages work together, document the composition patterns in your application.

Next Steps

Architecture

Learn about Remix’s package architecture

Web Standards

See how web standards enable composition

Middleware Guide

Deep dive into middleware patterns

Fetch Router

Explore the composable router

Build docs developers (and LLMs) love