Frontend Plugins

Build a Module Federation remote that the core-ui host loads at runtime.

For the generator and first run see Create a Plugin; for component reuse see Shared UI Components; for host internals see Frontend & Plugin Loading.

src/config.tsx: the IUIConfig object

Every remote exposes ./config, which must export CONFIG typed as IUIConfig (from erxes-ui). The host loads it once at startup and builds navigation, routes, settings, and widgets from it:

FieldTypePurpose
namestringRemote name without _ui; the host loads exposes ./<name> and ./<name>Settings.
pathstringURL prefix; the host mounts /<path>/*.
iconReact.ElementTypeOptional plugin icon (use @tabler/icons-react).
navigationGroup{ name, defaultPath?, icon, content(), subGroup?() }Sidebar group; content/subGroup render lazy nav components.
settingsNavigation() => ReactNodeEntries under Settings.
modules{ name, icon?, path, hasAutomation?, hasRelationWidget?, hasFloatingWidget?, hasSegmentConfigWidget? }[]Feature modules; path is relative to path.
widgets{ relationWidgets?, customerDetailWidgets?, formWidgets?, propertyInputs?, activityRows? }Named widgets other modules render.
searchProvidersISearchProvider[]Global-search providers.
i18n, i18nNamespaceboolean, stringTranslation namespace the host loads.
hasFloatingWidget, settingsOnlybooleanBehavior flags.

sales_ui's config is the worked example: navigationGroup with defaultPath: 'sales/deals' and a subGroup, four modules, relationWidgets, a propertyInputs map, and searchProviders.

module-federation.config.ts

The generated config declares name: '<name>_ui' and four exposes:

exposes: {
  './config': './src/config.tsx',
  './inventory': './src/modules/InventoryMain.tsx',
  './inventorySettings': './src/modules/InventorySettings.tsx',
  './widgets': './src/widgets/Widgets.tsx',
},

The expose names ./<name> and ./<name>Settings are fixed, not a convention: the host's usePluginsRouter mounts /<path>/* to loadRemote('<name>_ui/<name>') and settings routes to loadRemote('<name>_ui/<name>Settings'). Rename or move an expose and the host 404s. Sales adds more exposes (./relationWidget, ./notificationWidget, ./automationsWidget, …) that other modules request by name.

The shared function returns the default config for the coreLibraries set (react, react-dom, react-router, react-router-dom, erxes-ui, @apollo/client, jotai, ui-modules, react-i18next) and false for everything else. These become singletons shared with the host; anything outside the set is bundled per-plugin.

rspack.config.ts composes withNx(), withReact(), and withModuleFederation(config, { dts: false }); rspack.config.prod.ts re-exports it. Both need their default exports; the Nx/Rspack tooling is the one place a default export is allowed.

How the host loads remotes

  • Development: pnpm dev:uis runs nx serve core-ui --devRemotes="<name>_ui …". Each plugin's serve target (@nx/rspack:module-federation-dev-server) serves remoteEntry.js on its project.json port; core-ui's remotes list is generated from ENABLED_PLUGINS as <name>_ui names.
  • Production: core-ui's bootstrap.tsx fetches ${REACT_APP_API_URL}/get-frontend-plugins, which returns { name, entry } objects pointing at https://plugins.erxes.io/<releaseVersion>/<name>_ui/remoteEntry.js, and calls init({ remotes }) at runtime. The nginx config disables caching on remoteEntry.js.

At runtime RenderPluginsComponent calls loadRemote('<pluginName>/<remoteModuleName>') and finds a component from the module using a candidate-key list (default, PascalCase name, <Name>Component, <Name>RemoteEntry/RemoteEntries/Widget, AutomationRemoteEntries). All exposed modules must still use named exports; never rely on default to satisfy the loader.

Routing inside the remote

The host mounts /<path>/*; your exposed main component serves everything below it with its own <Routes>:

// generated pattern — sales does the same with /deals and /pos
export const InventoryMain = () => (
  <Suspense fallback={<Spinner />}>
    <Routes>
      <Route index element={<Navigate to="items" replace />} />
      <Route path="/items" element={<IndexPage />} />
    </Routes>
  </Suspense>
);

Keep config.tsx path, modules[].path, navigationGroup.defaultPath, and these routes aligned; a path the config advertises but no route serves renders an empty remote.

i18n

Set i18n: true (namespace = name) or i18nNamespace: '<ns>' in CONFIG. The host calls i18n.loadNamespaces(ns), which fetches ${REACT_APP_API_URL}/locales/<lng>/<ns>.json. The gateway serves those from backend/gateway/src/locales/<lng>/<file> and falls back to asking each plugin API for /locales/<lng>/<file>, so a plugin can serve its own namespace by mounting a /locales route through startPlugin's expressRouter. react-i18next is a shared singleton, so useTranslation() works directly in plugin code.

Data, state, and the real-time rule

  • Apollo Client (shared singleton) for server state; keep GraphQL documents next to the feature and prefix operation names with the plugin or module.
  • Jotai for plugin-wide client state, React state for component-local state.
  • React Hook Form + Zod for forms; provide loading, empty, success, and error states. No button without a handler.
  • After any create/update/delete, update the Apollo cache, refetch the affected query, or subscribeToMore; the UI must never require a manual refresh.
  • Lazy-load exposed modules inside Suspense.

Ports

The generated project.json serve port is 3005, which sales_ui already uses. Ports already in use: 3002–3011 (content 3003, frontline 3004, sales 3005, operation 3006, the rest held by Enterprise Edition remotes) plus host 3001. Pick a free port.

Was this helpful?