Skip to content

Config-driven common route loaders (apply a loader to every app route) #278

Description

@arbrandes

Description

(This was suggested by @MaxFrank13 here).

There is currently no way to apply a cross-cutting React Router loader to every app route from site config. Any loader a site operator wants to run site-wide, whether an authentication gate, a feature-flag check, telemetry, a tenant or entitlement lookup, or shared data prefetch, has to be attached by hand to each route in each app's routes.tsx. An operator who does not own those apps has no declarative lever: they must rewrite the whole apps[].routes tree in site.config.tsx, composing with each route's existing loader and recursing into children themselves. That is fragile, easy to get wrong, and has to be redone whenever an app is added.

This proposes a config-driven way to declare loaders that run across app routes, with a per-loader scope so each loader can be narrowed to the subset of routes it should touch.

Proposed approach

Add an optional commonLoaders field to SiteConfig, analogous to the existing commonAppConfig. Each entry pairs a LoaderFunction with an optional scope. The framework stays agnostic about what any given loader does; scoping is expressed entirely by the config author, who is the one who knows each loader's purpose.

Scope is expressed with two optional predicates over the route, include and exclude. A loader with neither predicate applies to every route. When include is present, the loader applies only to routes for which it returns true. When exclude is present, the loader skips routes for which it returns true. When both are present, a route is in scope when it is included and not excluded, so exclude takes precedence within the included set. Predicates receive the RouteObject, so they can test path, handle.roles, or anything else already on the route, which keeps role-based selection available as one option among many rather than the mechanism.

commonLoaders: [
  { loader: authenticatedLoader, exclude: (route) => hasRole(route, PUBLIC_ROLES) },
  { loader: featureFlagLoader, include: (route) => route.path?.startsWith('beta') },
  { loader: telemetryLoader }, // no scope: every route
]

Application happens centrally in getAppRoutes() (shell/router/getAppRoutes.ts), the single point where all app routes are already flattened. For each route, the in-scope common loaders are composed in front of the route's own loader: they run in declared order, and if one returns a Response/redirect that result short-circuits, otherwise the route's original loader runs. Composition recurses into children, and scope is evaluated per route node. A small hasRole helper can ship alongside as a convenience for role-based predicates, without the core feature depending on roles or on authentication.

Implementation sketch

// shell/router/getAppRoutes.ts
interface CommonLoader {
  loader: LoaderFunction;
  include?: (route: RouteObject) => boolean;
  exclude?: (route: RouteObject) => boolean;
}

function inScope(entry: CommonLoader, route: RouteObject): boolean {
  const included = entry.include ? entry.include(route) : true;
  const excluded = entry.exclude ? entry.exclude(route) : false;
  return included && !excluded;
}

function composeLoaders(route: RouteObject, commonLoaders: CommonLoader[]): RouteObject {
  const applicable = commonLoaders.filter((entry) => inScope(entry, route));
  const own = route.loader;
  return {
    ...route,
    loader: applicable.length === 0 ? own : async (args) => {
      for (const { loader } of applicable) {
        const result = await loader(args);
        if (result) {
          return result; // redirect/Response short-circuits
        }
      }
      return typeof own === 'function' ? own(args) : null;
    },
    children: route.children?.map((child) => composeLoaders(child, commonLoaders)),
  };
}

getAppRoutes() maps the flattened routes through composeLoaders when getSiteConfig().commonLoaders is set, leaving current behavior unchanged when it is not.

LLM usage notice

Built with assistance from Claude.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions