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:
File Storage
S3 Storage
Memory Storage
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' )
import { createFileStorage } from 'remix/file-storage'
import { createS3Backend } from 'remix/file-storage-s3'
// Swap backend without changing application code
let storage = createFileStorage ({
backend: createS3Backend ({
bucket: 'my-bucket' ,
region: 'us-east-1' ,
}),
})
// Same API, different backend
await storage . set ( 'avatar.jpg' , file )
let file = await storage . get ( 'avatar.jpg' )
import { createFileStorage } from 'remix/file-storage'
import { createMemoryBackend } from 'remix/file-storage/memory'
// Use in-memory storage for testing
let storage = createFileStorage ({
backend: createMemoryBackend (),
})
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] ' })
import { createTable } from 'remix/data-table'
import { createPostgresAdapter } from 'remix/data-table-postgres'
let users = createTable ( 'users' , {
columns: {
id: { type: 'integer' , primaryKey: true },
name: { type: 'text' },
email: { type: 'text' },
},
adapter: createPostgresAdapter ({ connectionString: process . env . DATABASE_URL }),
})
await users . insert ({ name: 'Alice' , email: '[email protected] ' })
import { createTable } from 'remix/data-table'
import { createMysqlAdapter } from 'remix/data-table-mysql'
let users = createTable ( 'users' , {
columns: {
id: { type: 'integer' , primaryKey: true },
name: { type: 'text' },
email: { type: 'text' },
},
adapter: createMysqlAdapter ({ host: 'localhost' , database: 'myapp' }),
})
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