13 KiB
Middleware and Server Setup
What This Covers
How to compose the request lifecycle and bridge the router to a runtime. Read this when the task involves:
- Choosing or ordering built-in middleware in the stack
- Writing custom middleware that sets typed context values
- Adding fast-exit handling (static files, CORS preflights) versus request-enriching layers (sessions, auth, data loading)
- Choosing when to keep the generated Node server versus switching server adapters
- Running the app server under development HMR
For data and persistence specifics, see data-and-validation.md. For session and auth specifics, see auth-and-sessions.md. For browser asset HMR, see assets-and-browser-modules.md.
Middleware Stack
Middleware runs in order for every request. Place fast-exit middleware (static files) early and request-enriching middleware (session, auth) later.
Recommended ordering:
import { createRouter } from 'remix/router'
import { compression } from 'remix/middleware/compression'
import { formData } from 'remix/middleware/form-data'
import { logger } from 'remix/middleware/logger'
import { methodOverride } from 'remix/middleware/method-override'
import { session } from 'remix/middleware/session'
import { staticFiles } from 'remix/middleware/static'
import { asyncContext } from 'remix/middleware/async-context'
let middleware = []
if (process.env.NODE_ENV === 'development') {
middleware.push(logger())
}
middleware.push(compression())
middleware.push(staticFiles('./public'))
middleware.push(formData())
middleware.push(methodOverride())
middleware.push(session(cookie, storage))
middleware.push(asyncContext())
middleware.push(loadDatabase())
middleware.push(loadAuth())
let router = createRouter({ middleware })
Built-in middleware catalog
| Middleware | Import | Use when | Notes |
|---|---|---|---|
staticFiles(dir, opts?) |
remix/middleware/static |
Serve files from public/ or another directory exactly as they exist on disk |
Fast exit; usually near the top |
compression() |
remix/middleware/compression |
Compress text-like responses | Usually app-wide |
logger() |
remix/middleware/logger |
Log requests and responses | Often development-only; colors can force color output on/off |
cors(opts?) |
remix/middleware/cors |
Endpoints must serve cross-origin browsers or preflight OPTIONS requests |
Usually early so preflights can short-circuit |
cop(opts?) |
remix/middleware/cop |
Reject unsafe cross-origin browser requests without synchronizer tokens | Put before session or CSRF when used |
formData(opts?) |
remix/middleware/form-data |
Parse FormData bodies, especially forms and uploads |
Needed for _csrf form field extraction |
methodOverride() |
remix/middleware/method-override |
HTML forms need PUT, PATCH, or DELETE semantics |
Run after form parsing |
session(cookie, storage) |
remix/middleware/session |
Cookie-backed sessions | Must run before session-backed auth or CSRF |
csrf(opts?) |
remix/middleware/csrf |
Session-backed form workflows need synchronizer-token CSRF protection | Requires session() before it |
asyncContext() |
remix/middleware/async-context |
Helpers outside handlers need request context via getContext() |
Add before helpers rely on it |
auth({ schemes }) |
remix/middleware/auth |
Resolve auth state into context.get(Auth) |
Run after session() for session-backed auth |
requireAuth() |
remix/middleware/auth |
A controller or action must reject anonymous access | Usually controller middleware or action middleware |
Static files vs browser modules
- Use
staticFiles()for files that should be served directly from disk, such as images, fonts, or already-built assets in the rootpublic/directory - Use
remix/assetswhen browser modules should be compiled and served from source files with import rewriting, preloads, or fingerprinted URLs public/directories insideapp/hold browser-reachable source for the asset server
Ordering notes
- Put fast exits early:
staticFiles(),cors()preflight handling, andcop()when used - Parse request bodies before middleware that depends on them, such as
methodOverride()and form field token extraction incsrf() - Run
session()beforecsrf()and before session-backedauth() - Add
asyncContext()before helpers or shared code callgetContext() - Keep route protection like
requireAuth()as controller middleware or action middleware unless the entire app is private
Common stacks
- Session-backed HTML app ->
compression(),staticFiles(), optionalcop(),formData(),methodOverride(),session(), optionalcsrf(),asyncContext(),auth({ schemes }) - Cross-origin API ->
compression(),cors(), optionalasyncContext(), optionalauth({ schemes }) - Upload flow ->
compression(),staticFiles(),formData({ uploadHandler }), then sessions, auth, and data-loading middleware as needed - Optional development HMR -> keep
server.tsas the child app server, addhmr.tsforremix/node-hmr, and proxy public requests throughcreateHmrReadyFetch()
Middleware with options
// Static files with cache headers
staticFiles('./public', {
cacheControl: 'no-store, must-revalidate',
etag: false,
lastModified: false,
})
// Form data with upload handler
import type { FileUpload } from 'remix/form-data-parser'
import { createFsFileStorage } from 'remix/file-storage/fs'
let fileStorage = createFsFileStorage('./tmp/uploads')
formData({
uploadHandler(fileUpload: FileUpload) {
return fileStorage.set(fileUpload.name, fileUpload)
},
})
Errors thrown or rejected by uploadHandler propagate directly. Catch domain-specific upload errors at the route boundary when they should become user-facing Response objects.
Writing Custom Middleware
Middleware is a function that receives (context, next). Return a Response to short-circuit, call and return next() when you need the downstream response, or return nothing when you only set context and want the router to continue automatically.
Setting context values
Use context.set(key, value) to add typed values accessible downstream via context.get(key).
import type { Middleware } from 'remix/router'
import { databaseContext } from '~/middleware/database.ts'
export function loadDatabase(): Middleware {
return async (context, next) => {
context.set(databaseContext, db)
return next()
}
}
Guarding routes
import { Auth } from 'remix/middleware/auth'
export function requireAdmin(): Middleware {
return (context, next) => {
let auth = context.get(Auth)
if (auth.identity?.role !== 'admin') {
return new Response('Forbidden', { status: 403 })
}
return next()
}
}
Async context for helpers
asyncContext() stores the request context in AsyncLocalStorage so helpers can reach it without the context being threaded through every call. Wrap getContext() in app-specific helpers:
// app/utils/context.ts
import { getContext } from 'remix/middleware/async-context'
import { Auth } from 'remix/middleware/auth'
import { databaseContext } from '~/middleware/database.ts'
import { Session } from 'remix/session'
export function getCurrentDb() {
return getContext().get(databaseContext)
}
export function getCurrentSession() {
return getContext().get(Session)
}
export function getCurrentUser() {
let auth = getContext().get(Auth)
if (!auth.ok) {
throw new Error('Expected an authenticated user. Run requireAuth() before this code.')
}
return auth.identity
}
export function getCurrentUserSafely() {
let auth = getContext().get(Auth)
return auth.ok ? auth.identity : null
}
Middleware Types
Middleware has three API-owned forms:
-
Router middleware — runs for every request:
let router = createRouter({ middleware: [logger(), session(cookie, storage)] }) -
Controller middleware — runs for the direct actions in one controller:
export default createController(routes.account, { middleware: [requireAuth()], actions: { ... }, })Controller middleware does not flow into other controllers. Add the middleware to each controller that needs it.
-
Action middleware — runs for a single action:
router.get(routes.account.index, { middleware: [requireAuth()], handler(context) { return render(<AccountPage identity={context.auth.identity} />) }, })
Prefer inline arrays for middleware options. Use RouterContext<typeof router> to derive an app context from a router that uses inline middleware. Use createMiddleware() only when a chain is stored in a variable and its exact tuple type needs to be preserved, such as when deriving MiddlewareContext<typeof rootMiddleware> without a router value, exporting a reusable chain, or returning a chain from a factory.
Node Server Setup
New apps already include a server.ts that adapts the app router with remix/node-fetch-server. Keep that generated server unless the task specifically needs to change runtime behavior such as host/protocol handling, TLS, HTTP/2, WebSockets, deployment lifecycle, or test-only server setup.
Use remix/node-fetch-server when you want to keep owning a standard Node http, https, or http2 server directly.
Development HMR
Treat HMR as an optional mode for rapid UI edits. Keep HMR supervision out of normal app code:
put the real app server in server.ts, keep the dev script running it directly (usually with
Node's watch mode), then add a development-only hmr.ts behind an hmr script when the project
benefits from HMR.
// hmr.ts
import * as http from 'node:http'
import { createFetchProxy } from 'remix/fetch-proxy'
import { createHmrReadyFetch, run } from 'remix/node-hmr'
import { createRequestListener } from 'remix/node-fetch-server'
const hmrProxyPort = 44100
const hmrEventPort = 44101
const appPort = 44102
const hmrRunner = run('./server.ts', {
env: {
...process.env,
PORT: String(appPort),
HMR_PROXY_PORT: String(hmrProxyPort),
},
nodeArgs: ['--import', 'remix/node-tsx', '--import', 'remix/ui-hmr/node'],
browserHmrChannel: { port: hmrEventPort },
})
let proxyFetch = createFetchProxy(`http://127.0.0.1:${appPort}`, {
xForwardedHeaders: true,
})
let server = http.createServer(createRequestListener(createHmrReadyFetch(hmrRunner, proxyFetch)))
server.listen(hmrProxyPort, '127.0.0.1')
Keep browserHmrChannel.port stable so browser HMR clients can reconnect to the same event channel if the dev server is manually restarted.
Use a stable public proxy when browser requests may happen while the child server is restarting. createHmrReadyFetch() waits for the active child generation before forwarding requests and retries safe unavailable responses when the child changes during a request.
In the child server.ts, report readiness after the server is listening:
server.listen(port, () => {
if (process.env.REMIX_NODE_HMR) {
import('remix/node-hmr/runtime').then((nodeHmr) => nodeHmr.emitServerReady())
}
})
Rules:
- Guard
remix/node-hmr/runtimeimports withprocess.env.REMIX_NODE_HMR. - Use
--import remix/ui-hmr/nodeonly when server-rendered Remix UI component modules should hot update. - Keep the default development and production startup paths independent from
hmr.ts. - Close the public server and
hmrRunnerduringSIGINTandSIGTERMshutdown.