remix-run/remix · error · TypeError
Expected a controller with an object `actions` property
Error message
Expected a controller with an object `actions` property
What it means
Thrown by mapRoutes in @remix-run/fetch-router when router.map() receives a routes object but the corresponding handler is not a controller, i.e. not an object with an `actions` property. When mapping multiple routes at once, fetch-router expects a controller whose actions object keys match the routes keys; single-route targets accept plain handlers.
Source
Thrown at packages/fetch-router/src/lib/router.ts:409
}
function addRoute(
method: RequestMethod | 'ANY',
route: RouteTarget,
action: unknown,
state: BuilderState,
): void {
registerRoute(method, route, normalizeAction(action), state)
}
function mapRoutes(target: MapTarget, handler: unknown, state: BuilderState): void {
if (isRouteTarget(target)) {
mapSingleRoute(target, handler, state)
return
}
if (!isController(handler)) {
throw new TypeError('Expected a controller with an object `actions` property')
}
mapController(target, handler, state)
}
function mapSingleRoute(target: RouteTarget, handler: unknown, state: BuilderState): void {
registerRoute(getMappedRouteMethod(target), target, normalizeAction(handler), state)
}
function mapController(
routes: RouteMap,
controller: {
middleware?: readonly AnyMiddleware[] | undefined
actions: Record<string, unknown>
},
state: BuilderState,
): void {
let controllerMiddleware = normalizeMiddleware(controller.middleware)View on GitHub (pinned to 9696913134)
Solutions
- If mapping one route, pass a single Route target (e.g. route(...)) so mapSingleRoute runs
- If mapping multiple routes, wrap handlers in { actions: { key: handler } }
- Check the `actions` property spelling and that it is a plain object
- Verify controller shape matches the version of fetch-router you depend on
Example fix
// before
router.map({ '/a': route(get, '/a'), '/b': route(post, '/b') }, handleAll)
// after
router.map(
{ '/a': route(get, '/a'), '/b': route(post, '/b') },
{ actions: { '/a': handleA, '/b': handleB } },
) Defensive patterns
Strategy: type-guard
Validate before calling
let isControllerLike = (v: unknown) => typeof v === 'object' && v !== null && typeof (v as any).actions === 'object'
Type guard
function isController(value: unknown): value is { actions: Record<string, unknown>; middleware?: unknown } {
return typeof value === 'object' && value !== null && typeof (value as { actions?: unknown }).actions === 'object'
} Try / catch
catch (error) { if (error instanceof TypeError && /Expected a controller/.test(error.message)) throw new Error(`Bad router.map() call: ${error.message}`) throw error } Prevention
- Pass single Route targets when mapping one route, controllers when mapping a map
- Let TypeScript infer/check the map-vs-handler pairing at compile time
- Smoke-test route registration in CI
When it happens
Trigger: router.map({ '/a': ..., '/b': ... }, plainFunction) or router.map(routesObject, { notActions: {...} }). Any multi-key routes target paired with a non-controller handler.
Common situations: Passing a single handler function for a routes map, typoing `actions` (e.g. `action` or `handlers`), or passing a controller instance from a different library/version whose shape changed.
Understand the failure class
Background: Invalid argument type errors: "must be of type string", "expected X, got Y", and ERR_INVALID_ARG_TYPE explained — this error's family across 15 libraries.
Related errors
- Expected a request handler function or action object with a
- hmr must create a channel with a close function
- hmr must create a channel with an onFileEvents function
- hmr must create a channel with an updateWatchedFiles functio
- basePath must be a string
AI-assisted analysis of remix-run/remix@9696913134 (2026-08-27).
Data as JSON: /api/errors/f91bf1d354f66232.
Report an issue: GitHub.