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:
| Field | Type | Purpose |
|---|---|---|
name | string | Remote name without _ui; the host loads exposes ./<name> and ./<name>Settings. |
path | string | URL prefix; the host mounts /<path>/*. |
icon | React.ElementType | Optional plugin icon (use @tabler/icons-react). |
navigationGroup | { name, defaultPath?, icon, content(), subGroup?() } | Sidebar group; content/subGroup render lazy nav components. |
settingsNavigation | () => ReactNode | Entries 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. |
searchProviders | ISearchProvider[] | Global-search providers. |
i18n, i18nNamespace | boolean, string | Translation namespace the host loads. |
hasFloatingWidget, settingsOnly | boolean | Behavior 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:uisrunsnx serve core-ui --devRemotes="<name>_ui …". Each plugin'sservetarget (@nx/rspack:module-federation-dev-server) servesremoteEntry.json itsproject.jsonport; core-ui'sremoteslist is generated fromENABLED_PLUGINSas<name>_uinames. - Production: core-ui's
bootstrap.tsxfetches${REACT_APP_API_URL}/get-frontend-plugins, which returns{ name, entry }objects pointing athttps://plugins.erxes.io/<releaseVersion>/<name>_ui/remoteEntry.js, and callsinit({ remotes })at runtime. The nginx config disables caching onremoteEntry.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.