Problem
fetch-router has treated controllers as recursive mirrors of a route-map subtree. When an app calls router.map(routes, controller), the controller is expected to define actions for every route leaf in the entire passed route map, including nested route maps.
That creates an ergonomics and ownership ambiguity:
- A controller sometimes means “actions for this route-map level” and sometimes means “the whole nested controller tree”.
- Middleware on one controller flows into controllers for nested route maps, even though those controllers do not otherwise inherit things like URL segments or route-map structure at runtime.
- App file conventions have to distinguish between standalone leaf action files and controller directories.
remix doctor has extra logic to decide whether a route should be represented by a standalone action file or a controller entry file, and to report drift when a leaf action grows route-local files.
This is especially awkward at the root route-map level. In demos like bookstore, root leaf routes are standalone action files under app/controllers, while grouped routes are controller directories. We could not cleanly collect those root actions into a root controller because router.map(routes, controller) would also require bringing in the entire nested controller tree.
Desired model
A controller defines actions for the direct leaf routes in the route map passed to router.map(). It does not include nested route-map keys, and it does not compose controllers registered for nested route maps.
For example:
const routes = route({
home: '/',
auth: {
login: form('/login'),
logout: post('/logout'),
},
})
Controller<typeof routes> should require home, but should reject auth because auth is a nested route-map key, not a direct leaf route.
Controller<typeof routes.auth> should require login and logout.
Router wiring becomes explicit:
router.map(routes, rootController)
router.map(routes.auth, authController)
router.map(routes.auth.login, loginController)
Controller middleware applies only to the direct actions in that controller. Nested route groups should opt into middleware explicitly in their own controller.
App convention
Rename app-owned route action modules from app/controllers to app/actions.
The owner entry for a route-map level is always a controller.ts(x) file under route-map key segments, not URL path segments:
app/actions/controller.tsx # Controller<typeof routes>
app/actions/auth/controller.tsx # Controller<typeof routes.auth>
app/actions/auth/reset-password/controller.tsx # Controller<typeof routes.auth.resetPassword>
This avoids repetitive root naming like app/controllers/controller.tsx, and removes the distinction between standalone action files and controller files. Everything in app/actions is a controller entry for a route-map level.
Plan
- Update
fetch-router controller types so Controller<routes> includes only direct first-level Route leaves.
- Update
router.map(routeMap, controller) runtime behavior to register only direct leaf routes.
- Reject missing direct leaf actions, nested route-map keys in
actions, and unknown action keys at runtime.
- Keep controller middleware scoped to direct actions and update tests to verify it does not flow into explicitly mapped controllers for nested route maps.
- Update
fetch-router README, tests, and release notes for the breaking controller semantics change.
- Refactor CLI route ownership around
app/actions and a single controller owner kind.
- Remove CLI logic for standalone action owners, wrong owner kind, and promotion drift.
- Add the synthetic root owner
app/actions/controller.tsx.
- Keep route-key based directory naming using the existing disk segment normalization, e.g.
resetPassword -> reset-password.
- Update
remix doctor diagnostics, fix generation, fixtures, and tests to use app/actions.
- Update
remix routes owner output to point at app/actions controller files.
- Migrate demos from
app/controllers to app/actions.
- Move root leaf routes in demos into root controllers where applicable, especially bookstore.
- Split existing recursive controller objects into explicit
router.map(...) calls.
- Duplicate middleware explicitly in each controller where shared protection is needed.
- Update demo READMEs, bootstrap templates, AGENTS guidance, and repo skills such as
make-demo to describe the app/actions convention.
Expected outcome
The routing model becomes easier to explain:
- Route maps define URL and key structure.
- Controllers define direct actions for one route map.
- Middleware belongs to the controller where it is declared.
- App action files consistently live under
app/actions/<route-key-segments>/controller.tsx.
This should reduce API ambiguity, simplify app organization, and remove a meaningful amount of special-case ownership logic from the CLI.
Problem
fetch-routerhas treated controllers as recursive mirrors of a route-map subtree. When an app callsrouter.map(routes, controller), the controller is expected to define actions for every route leaf in the entire passed route map, including nested route maps.That creates an ergonomics and ownership ambiguity:
remix doctorhas extra logic to decide whether a route should be represented by a standalone action file or a controller entry file, and to report drift when a leaf action grows route-local files.This is especially awkward at the root route-map level. In demos like bookstore, root leaf routes are standalone action files under
app/controllers, while grouped routes are controller directories. We could not cleanly collect those root actions into a root controller becauserouter.map(routes, controller)would also require bringing in the entire nested controller tree.Desired model
A controller defines actions for the direct leaf routes in the route map passed to
router.map(). It does not include nested route-map keys, and it does not compose controllers registered for nested route maps.For example:
Controller<typeof routes>should requirehome, but should rejectauthbecauseauthis a nested route-map key, not a direct leaf route.Controller<typeof routes.auth>should requireloginandlogout.Router wiring becomes explicit:
Controller middleware applies only to the direct actions in that controller. Nested route groups should opt into middleware explicitly in their own controller.
App convention
Rename app-owned route action modules from
app/controllerstoapp/actions.The owner entry for a route-map level is always a
controller.ts(x)file under route-map key segments, not URL path segments:This avoids repetitive root naming like
app/controllers/controller.tsx, and removes the distinction between standalone action files and controller files. Everything inapp/actionsis a controller entry for a route-map level.Plan
fetch-routercontroller types soController<routes>includes only direct first-levelRouteleaves.router.map(routeMap, controller)runtime behavior to register only direct leaf routes.actions, and unknown action keys at runtime.fetch-routerREADME, tests, and release notes for the breaking controller semantics change.app/actionsand a single controller owner kind.app/actions/controller.tsx.resetPassword->reset-password.remix doctordiagnostics, fix generation, fixtures, and tests to useapp/actions.remix routesowner output to point atapp/actionscontroller files.app/controllerstoapp/actions.router.map(...)calls.make-demoto describe theapp/actionsconvention.Expected outcome
The routing model becomes easier to explain:
app/actions/<route-key-segments>/controller.tsx.This should reduce API ambiguity, simplify app organization, and remove a meaningful amount of special-case ownership logic from the CLI.