Frontend & Plugin Loading

How the Core UI host discovers and loads plugin micro-frontends at runtime.

For generating a new remote, see Create a Plugin and Frontend Plugins. For the backend half of plugin registration, see Backend & API Gateway.

Host and remotes

erxes uses Module Federation (@module-federation/enhanced on Rspack) with one host and one remote per frontend plugin.

  • Host: frontend/core-ui. src/main.ts dynamically imports src/bootstrap.tsx, which initializes Sentry and then the federation runtime before rendering App. The dev server listens on 3001.
  • Remotes: frontend/plugins/<name>_ui, each an Nx project with its own module-federation.config.ts, rspack.config.ts, and dev port.
  • Shared singletons: every module-federation.config.ts shares the same coreLibraries set (react, react-dom, react-router, react-router-dom, erxes-ui, @apollo/client, jotai, ui-modules, react-i18next, radix-ui), so all remotes run against the host's copies. Anything not in the set is bundled per remote.

Plugin dev ports (Community Edition): content 3003, frontline 3004, sales 3005, operation 3006.

Development versus production

In development, the host's module-federation.config.ts builds its remotes list from ENABLED_PLUGINS (sales becomes sales_ui), and scripts/start-ui-dev.js passes them to Nx as --devRemotes. bootstrap.tsx then renders <App /> directly. Run:

pnpm dev:uis

with ENABLED_PLUGINS=sales in .env to serve core-ui and sales_ui together.

In production, bootstrap.tsx instead fetches ${REACT_APP_API_URL}/get-frontend-plugins and passes the response to init({ name: 'core', remotes }) before rendering. Core API serves that list in organization/routes.ts:

[{ "name": "sales_ui", "entry": "https://plugins.erxes.io/latest/sales_ui/remoteEntry.js" }]
  • The remote name is <plugin>_ui with dashes converted to underscores (federation container names cannot contain dashes).
  • The entry is https://plugins.erxes.io/<version>/<plugin>_ui/remoteEntry.js. <version> is the releaseVersion the plugin's API registered in Redis (RELEASE_VERSION when it starts with 3., else latest). Under VERSION=saas, the list is additionally filtered by the organization's purchased charges and always includes agent_ui.
  • Plugin UIs are not Docker images: the ci-ui-* workflows build each remote and aws s3 sync it to an R2 bucket that fronts plugins.erxes.io, under latest/ or the release tag. A UI release therefore takes effect through the CDN, not a container redeploy.

If the fetch fails, the host renders ClientConfigError instead of a broken app. A plugin that registers in Redis but whose remoteEntry.js is unreachable still appears in /get-frontend-plugins; check the browser console for loadRemote errors on <name>_ui/config.

How config.tsx is consumed

After init, PluginConfigsProvidersEffect (PluginConfigsProvidersEffect.tsx) calls loadRemote('<remote>/config') for every registered remote and stores each module's CONFIG export in the pluginsConfigState Jotai atom. The CONFIG object follows IUIConfig from erxes-ui (UIConfig.ts):

FieldConsumed by
name, pathRoute registration and permission guard
navigationGroupMain navigation rail: icon, defaultPath, content, subGroup
settingsNavigationSettings sidebar entries
modules[]Route entries (name, path, hasAutomation, widget flags)
widgetsrelationWidgets, customerDetailWidgets, formWidgets, propertyInputs, activityRows
searchProvidersGlobal search providers for command search
i18n / i18nNamespaceExtra translation namespaces loaded on demand

Routing works by convention:

  • usePluginsRouter (usePluginsRouter.tsx) mounts /<config.path>/* and lazy-loads <pluginName>_ui/<module.name> through RenderPluginsComponent, guarded by PermissionRouteGuard.
  • Settings routes load <pluginName>_ui/<plugin.name>Settings.
  • resolveRemoteComponent tries default, the PascalCase module name, <Name>Component, <Name>RemoteEntry, <Name>RemoteEntries, <Name>Widget, and finally any component-shaped export. Exposed modules must use named exports, so keep the convention rather than relying on the fallbacks.

What a remote exposes

Each remote declares exposes in its own module-federation.config.ts. Sales (sales_ui) is representative:

exposes: {
  './config': './src/config.tsx',
  './sales': './src/modules/Main.tsx',
  './dealsSettings': './src/pages/SalesSettingsIndexPage.tsx',
  './salesSettings': './src/pages/SalesSettingsIndexPage.tsx',
  './Widgets': './src/widgets/Widgets.tsx',
  './notificationWidget': './src/widgets/notifications/NotificationsWidgets.tsx',
  './relationWidget': './src/widgets/relation/RelationWidgets.tsx',
  './pos': './src/modules/pos/Main.tsx',
  './posSettings': './src/modules/pos/pos/Main.tsx',
  './automationsWidget': './src/widgets/automations/components/AutomationRemoteEntry.tsx',
},

./config is mandatory; it is the only module the host loads eagerly. Every other expose must match a CONFIG.modules path, a widget declaration, or a host reference. Keep expose names, config.tsx paths, and real routes aligned; see Frontend Plugins.

Was this helpful?