Creation Portal — End-to-End System Design
This single page explains the entire Creation Portal micro-frontend platform: how the host shell loads domain apps at runtime, how they are built, shipped, deployed, and how it all fits together. Everything here is grounded in the actual source of our repositories.
fe-microfrontend-portal, m3po-mfe-kit, lbr-frontend, rcs-fe-common, yarn-design-system, lbr-jenkins-dsl, lbr-deployments.
0 One-line summary
If you remember one paragraph, remember this one.
fe-microfrontend-portal) owns navigation, auth/permissions, feature flags, global history, and layout, and loads many independently built, versioned, and deployed domain micro-frontends on demand via Module Federation, using a custom loader (@m3po-mfe-kit/mfe-loader, built on @module-federation/runtime). The LBR domain MFEs live in one monorepo (lbr-frontend) with a shared common package, consume a shared component library (yarn-design-system) and shared platform packages (rcs-fe-common), persist per-user grid/view state via a user-preference service, are packaged as Nginx Docker images, deployed to Kubernetes via Kustomize (lbr-deployments), and built/promoted through Jenkins multibranch pipelines generated by Job DSL (lbr-jenkins-dsl). Promotion dev → sit → uat → pre → prod is an immutable image-tag promotion, not a rebuild.
1 The cast of repositories
Each box you'll touch, and what it's for.
| Repository | Role | Key tech |
|---|---|---|
fe-microfrontend-portal | The host shell (portal). Single entry point; owns chrome, routing, auth, feature flags, config loading, browser history. | React 18, React Router (BrowserRouter), @m3po-mfe-kit/*, yarn-design-system-icons |
m3po-mfe-kit | Reusable MFE loader toolkit: HostProvider, MicroFrontend, RemoteMicroFrontend, useLoadRemote, global-history. | @module-federation/runtime, React Query, TypeScript, Vite |
lbr-frontend | Monorepo of ~10 LBR domain MFEs + a shared local common package. Each MFE is an independent remote. | React 17, Webpack 5 MF, SWC, React Query, i18next, Biome, Jest |
rcs-fe-common | Shared platform packages (@rcs-fe-common/*): observability, message/error screens, permissions, breadcrumb, notify, router, global-history. | npm workspaces + Turbo, Node 24 |
yarn-design-system | The shared design-system component library (adidas YARN). Tokens, icons, React components, grid, docs, BFF. | Lerna + npm workspaces, SCSS, React |
lbr-jenkins-dsl | CI/CD as code. Job DSL seed jobs that generate the Jenkins folder structure and one multibranch pipeline per app. | Groovy Job DSL |
lbr-deployments | Kubernetes deployment manifests (Kustomize bases + per-env overlays) for all LBR frontend & backend services. | Kustomize, Nginx, Fortanix, Harbor external secrets, VPA |
2 High-level architecture diagram
The whole system on one screen. Everything else is a zoom-in on a part of this.
graph TD
Shell["Portal shell
fe-microfrontend-portal · React 18
BrowserRouter · auth · feature flags"] --> Loader["MFE loader · @m3po-mfe-kit
HostProvider · useLoadRemote"]
Loader -. "fetch remoteEntry.js on demand" .-> MFE["Domain micro-frontends · React 17
materials · colors · labels · artwork
hangtags · packaging · basicpattern
lasts · process · libraryselector"]
Shell -. "load config at boot" .-> CfgSrv["frontend-config-service
remotes · services · keys"]
MFE -. "view preferences" .-> UserPref["user-preference-service
grid columns · facets"]
MFE -. "React Query" .-> DomainApis["Domain REST APIs"]
MFE --> Common["lbr-frontend/common
MFE container · webpack · requests
i18n · view preferences"]
MFE --> YDS["yarn-design-system
host-provided webpack external"]
MFE --> RCS["rcs-fe-common
@rcs-fe-common/* packages"]
Shell --> RCS
DSL["lbr-jenkins-dsl
Job DSL"] --> Jenkins["Jenkins multibranch pipeline
one per MFE"]
Jenkins -->|"docker build · push"| Registry["Harbor container registry"]
Jenkins -->|"bump image tag"| Deploy["lbr-deployments
Kustomize bases · overlays"]
Registry --> K8s["Kubernetes (AWS)
Nginx pods per MFE"]
Deploy --> K8s
K8s -. "serves remote bundles" .-> MFE
classDef runtime fill:#102b42,stroke:#38bdf8,stroke-width:2px,color:#eff8ff
classDef services fill:#302343,stroke:#c084fc,stroke-width:2px,color:#f5edff
classDef shared fill:#12352f,stroke:#34d399,stroke-width:2px,color:#eafff5
classDef delivery fill:#3b2b16,stroke:#f59e0b,stroke-width:2px,color:#fff7e8
class Shell,Loader,MFE runtime
class CfgSrv,UserPref,DomainApis services
class Common,YDS,RCS shared
class DSL,Jenkins,Registry,Deploy,K8s delivery
linkStyle default stroke:#38bdf8,stroke-width:2px
3 The host shell — fe-microfrontend-portal
The portal is the single page application the user actually loads. It owns everything that must be consistent across the platform.
Responsibilities
Global chrome
Header (title "Creation Portal"), nav drawer, notifications, modals, upload panel, inactivity modal, global banner.
Auth / permissions
Resolves user permissions once via getPermissions(...) and shares them down.
Feature flags
featureFlagsService.flags drive conditional UI.
Config loading
Loads which remotes exist + service paths at boot (see §5).
Real browser history
BrowserRouter + a bridge so remotes can drive the URL.
Routing
Builds the route table and mounts the matching remote.
// remotes come from the config service (local defaults OR config-server in prod)
const [remotes] = useState(configService.getRemotes());
// map rich remote config down to the minimal shape the loader needs
const mfRemotes = useMemo(() => remotes.map(({ name, entry }) => ({ entry, name })), [remotes]);
// merge static routes with each remote's route
const appRoutes = useMemo(() => {
const microfrontendRoutes = Object.fromEntries(remotes.map(({ name, route }) => [name, route]));
return { ...ROUTES, ...microfrontendRoutes };
}, [remotes]);
return (
<HostProvider hostName="portal" remotes={mfRemotes}>
<GlobalHistoryBridge />
<ContextProvider permissions={userPermissions} routes={appRoutes} remotes={remotes} ...>
<Header title="Creation Portal" />
<Switch>
{remotes.map(({ name, route }) => (
<Route key={name} path={route}><AppContainer /></Route>
))}
<Route path="*"><Home /></Route>
</Switch>
</ContextProvider>
</HostProvider>
);
Global history bridge — lets a remote navigate the real browser URL
function GlobalHistoryBridge() {
const history = useHistory();
useGlobalHistoryListener({
back: history.goBack,
forward: history.goForward,
go: history.go,
push: (path, state) => history.push(path, state),
replace: (path, state) => history.replace(path, state),
});
return null;
}
4 The MFE loader — @m3po-mfe-kit/mfe-loader
The custom package that turns Module Federation into a clean React API. Built on @module-federation/runtime (dynamic, runtime-registered remotes — not build-time shared).
4.1 · HostProvider — creates the MF runtime instance
// contexts/host-provider.tsx
export const HostProvider = ({ remotes, hostName, children }) => {
const host = useMemo(
() => createInstance({ name: hostName, remotes }), // from @module-federation/runtime
[remotes, hostName],
);
return <HostContext.Provider value={host}>{children}</HostContext.Provider>;
};
A Remote is either entry-based { name, entry } (a URL to remoteEntry.js) or version-based { name, version }. The portal uses entry-based remotes.
4.2 · useLoadRemote — fetch a remote on demand (cached with React Query)
// hooks/use-load-remote.ts
export const useLoadRemote = (mfeName) => {
const host = useHost();
return useQuery({
queryKey: [RQ_KEY, mfeName],
queryFn: async () => {
const module = await host.loadRemote<{ default: CreateRemoteRoot }>(mfeName);
return module?.default ?? null; // the remote's exposed factory
},
cacheTime: Infinity,
staleTime: Infinity,
suspense: true, // integrates with <Suspense>
enabled: !!mfeName,
});
};
Because cacheTime/staleTime are Infinity, each remote is fetched once and reused for the session.
4.3 · RemoteMicroFrontend — mount the remote into a DOM node
// components/remote-micro-frontend/remote-micro-frontend.tsx
export const RemoteMicroFrontend = ({ containerId, mfeProps, name, ... }) => {
const { data: createRemoteRoot } = useLoadRemote(name);
const remoteRootRef = useRef(null);
const containerCallbackRef = useCallback((node) => {
if (node) remoteRootRef.current = createRemoteRoot(node); // { render, unmount }
else remoteRootRef.current?.unmount();
}, [createRemoteRoot]);
useLayoutEffect(() => {
remoteRootRef.current?.render(mfeProps); // (re)render with latest props
}, [mfeProps]);
return <div id={containerId} ref={containerCallbackRef} />;
};
4.4 · MicroFrontend — wraps a remote in its own QueryClientProvider + Suspense
5 Loading MFE configs — ConfigService
The portal does not hard-code which remotes exist in production. It loads them from a config server at boot.
- Local/default remotes come from
src/config/remotes.js(localhostremoteEntry.jsURLs for dev). - In production,
getConfigServerInfo()fetches/frontend-config-service/portal/rcs-microfe-portal.jsonand replacesremotes,services,instana,matomo,globalBanner,oauthProxy,asyncNotification. - Feature flags load from
/portal/rcs-feature-flags.json. - Service base paths are centralized and overridable locally via
LOCAL_SERVICES_OVERRIDES.
async getConfigServerInfo() {
const { data = {} } = await http.get(`${this.getService(CONFIG_SERVER)}${this.configEndpoints.config}`);
const { applications, remotes, services, instana, matomo, ... } = data;
const resolvedRemotes = remotes ?? applications ?? [];
this.updateServices(services);
if (ENV.PROD) {
this.remotes = resolvedRemotes; // prod remotes come from the server
this.instana[INSTANA_KEY] = instana?.key;
this.matomo[MATOMO_SITE] = matomo?.key;
}
}
Remote config shape (src/config/remotes.js)
{
name: 'libraryselector', // must match the remote's MF name
entry: 'http://localhost:8091/remoteEntry.js',
route: '/libraries', // host route that mounts it
icon: 'libraries-app', label: 'Libraries',
hide: true, // hide from nav drawer
permissions: 'CP_VIEW_MOS', // only load if user authorized
configServerUrl: `/configuration/${EXEC_ENVIRONMENT}-lbr/lbr-app-selector.json`,
containerId: 'mfe-container-...', // custom DOM container id
maintenanceMode: true, // short-circuit to maintenance screen
}
6 The LBR MFE monorepo — lbr-frontend
One monorepo (npm workspaces) containing ~10 domain MFEs plus a shared common package.
common; imports to common use aliases (@common/*, @root/common/*), never relative paths.6.1 · Module Federation config (one name per MFE)
common/config/webpack/moduleFederationConfigByPackageName.jsconst moduleFederationConfigByPackageName = {
'lbr-frontend-materials': { name: 'materials', exposes: { '.': './src/main.tsx' } },
'lbr-microfe-library-selector':{ name: 'libraryselector',exposes: { '.': './src/main.tsx' } },
'lbr-frontend-view-color': { name: 'colors', exposes: { '.': './src/main.tsx' } },
'lbr-microfe-labels': { name: 'labels', exposes: { '.': './src/main.tsx' } },
'lbr-microfe-hangtags': { name: 'hangtags', exposes: { '.': './src/main.tsx' } },
'lbr-microfe-basic-pattern': { name: 'basicpattern', exposes: { '.': './src/main.tsx' } },
'lbr-microfe-packaging': { name: 'packaging', exposes: { '.': './src/main.tsx' } },
'lbr-microfe-artwork': { name: 'artwork', exposes: { '.': './src/main.tsx' } },
'lbr-microfe-lasts': { name: 'lasts', exposes: { '.': './src/main.tsx' } },
'lbr-microfe-process': { name: 'process', exposes: { '.': './src/main.tsx' } },
};
Each MFE exposes a single entry (. → ./src/main.tsx).
6.2 · Shared webpack build (common/config/webpack/webpack.common.js)
output.libraryTarget: 'umd',chunkFilename: 'chunk-[name].[contenthash].js',publicPath: '/'.swc-loader+ts-loaderfor TS; CSS Modules (+.global.scss).ModuleFederationPlugin({ ...config, filename: 'remoteEntry.js', dts: false }).optimization.moduleIds: 'deterministic'; minimizers:CssMinimizerPlugin+TerserPlugin(SWC minify).yarn-design-systemandlbr-ag-grid-themeare externals (host-provided → not duplicated into every MFE bundle).
6.3 · Shared MFE container (common/src/components/mfe-container/mfe-container.tsx)
How the host's context reaches a remote. It builds a single mfeProps payload and wraps the remote in an error boundary:
const mfeProps = {
baseUrl,
url: `${pathname}${search}${hash}`,
configServerUrl: config.configServerUrl,
contexts: { ...contexts, routes: [routes] }, // permissions, breadcrumbs, notifications, routes
customConfig,
};
<RemoteErrorBoundary remoteName={resolvedRemoteName}>
<RemoteMicroFrontend name={resolvedRemoteName} containerId={containerId} mfeProps={mfeProps} />
</RemoteErrorBoundary>
- Failure isolation:
RemoteErrorBoundaryshows aMessageScreen(LOADING_ERROR) so one broken MFE never crashes the shell. - Maintenance mode: if
config.maintenanceMode, it renders a maintenance screen + observability event instead of loading the remote.
6.4 · Remote bootstrap (<mfe>/src/remote-render.tsx)
When a remote renders it:
- Loads translations (i18next).
- In prod, fetches server config (
configService.getConfigFromServer). - Extracts contexts (
breadcrumbs,permissions: [userInfo, permissionsApi],notifications,routes). - Initializes observability:
initObservability({ instanaKey, matomoKey, packageJsonVersion, id }). - Builds the provider tree:
StrictMode → QueryClientProvider → RoutesProvider → AppContextProvider → PermissionsProvider → BreadcrumbProvider → NotifyProvider → CustomBreadcrumbsProvider → ViewPreferencesProvider → UserAttributesProvider → UserInfoProvider → AssetUpdateStatusProvider → ImageStoreProvider → App. - Renders inside a
MemoryRouterkept in sync with the host URL. - Nested remotes: a domain MFE can itself be a host (e.g. materials declares child remotes via its own
<HostProvider hostName="materials-host">). Composition is recursive.
7 Routing & the "two React versions, one router" strategy
- Host =
BrowserRouter(ownswindow.history, decides which MFE mounts). - Each MFE =
MemoryRouter(owns its internal pages). - A small sync component keeps the MemoryRouter aligned with the host URL:
function UpdateMemoryRouterURL({ url }) {
const history = useHistory();
useEffect(() => {
const cur = history.location.pathname + history.location.search + history.location.hash;
if (url && cur !== url) history.replace(url);
}, [url, history]);
return null;
}
<MemoryRouter initialEntries={[initialEntry]}>
<UpdateMemoryRouterURL url={url ?? initialEntry} />
{app}
</MemoryRouter>
React versions: the host is React 18; the LBR MFEs are React 17. This works because integration is at runtime (each remote ships its own React as a UMD bundle and renders into a plain DOM node) — there is no MF shared singletons block forcing one React.
The one deliberate singleton — React Router: to avoid the "two router contexts" bug, a single react-router-dom instance is bridged via a window global:
function ensurePortalRouterDOM() {
const w = window as Window & { ReactRouterDOM?: typeof ReactRouterDOM };
w.ReactRouterDOM = { ...ReactRouterDOM, ...w.ReactRouterDOM };
}
8 Shared context across remotes (no coupling)
- The host resolves auth/permissions, feature flags, breadcrumbs, notifications, routes once.
- These are passed to each remote only through the
mfeProps.contextspayload (§6.3) and the global-history bridge (§3). - MFEs never import from each other. Any truly shared logic lives in
commonorrcs-fe-common. - Result: remotes are isolated (independent build/deploy/failure) yet behave as one consistent app.
sequenceDiagram
participant U as User
participant H as Portal (React 18)
participant L as mfe-loader (MF runtime)
participant C as common/mfe-container
participant R as Remote MFE (React 17)
U->>H: open /materials
H->>H: load config, permissions, flags (ConfigService)
H->>H: BrowserRouter matches route → AppContainer
H->>C: render container (remoteName, containerId)
C->>L: useLoadRemote('materials')
L->>L: host.loadRemote → fetch remoteEntry.js (cached ∞)
L-->>C: createRemoteRoot factory
C->>R: createRemoteRoot(node).render(mfeProps)
R->>R: bridge router, providers, MemoryRouter, observability
R->>R: load view preferences (grid config), fetch data (React Query)
R-->>U: rendered domain screen inside the shell
9 User preferences (grid / view config)
Per-user, per-library grid and filter state is handled by a shared provider in common: common/src/contexts/view-preferences.tsx.
type ViewPreferencesProviderConfig = {
library: ValueOf<typeof Libraries>; // which library/domain
scope?: string;
domainContext?: string;
defaultFacetValues?: DefaultFacetValues; // ship a default filter (e.g. hide Archived)
};
What the context exposes: facetSet, facetValues, columnPreferences (grid columns, incl. metadata.pageSize = DEFAULT_PAGE_SIZE), columnSnapshot, domain scope/domainContext, isLoading/isFetching/isError, plus updaters: updateFacetSet, updateFacetValues, updateColumnPreferences, updateDomainScope, forceColumnsUpdate, resetAllFacets.
Persistence: backed by React Query hooks (useGetViewPreferences, useUpdateColumnSet, useUpdateFacetSet, useUpdateFacetValues) that read/write to the user-preference-service (/papp/user-preference-service). An isPersisted flag distinguishes transient (in-session) changes from saved ones.
common and reused by every MFE.10 Shared platform packages — rcs-fe-common
A separate monorepo (npm workspaces + Turbo, Node 24) that publishes the @rcs-fe-common/* packages consumed by the portal and all MFEs.
Packages
observability-utility, message-screen, permissions, breadcrumb, notify, router, global-history, and more.
Build
turbo run build | lint | test (cached, per-package).
Publish
Versioned and published to the private registry.
Role
The platform-abstraction layer — consistent auth, notifications, error screens, observability everywhere.
import * as observabilityClient from '@rcs-fe-common/observability-utility';
import { MessageScreen } from '@rcs-fe-common/message-screen';
import { useGlobalHistoryListener } from '@m3po-mfe-kit/global-history';
rcs-fe-common vs lbr-frontend/common: rcs-fe-common = cross-platform packages shared by many adidas frontends (published to registry). lbr-frontend/common = LBR-local shared code (webpack config, MFE container, request layer, i18n, view-preferences), bundled into each MFE image via Jenkins includePackages: 'common'.@rcs-fe-common/fe-microfrontend-loader (script-injection loader) exists but is not used by the portal — the portal uses the m3po loader.11 Design system — yarn-design-system
A large Lerna + npm workspaces monorepo (adidas YARN design system).
- Packages: a core (SCSS tokens/base styles), asset packages (icons, logos, fonts, characters), integration wrappers (ag-grid, flatpickr, react-select, swiper, rich-text), the React component layer (
yarn-design-system-react-components), a docs site and a BFF. - Build:
npm run build --workspaces/lerna run build. - Publish: independent per-package versioning via
lerna exec -- safe-publish. - Consumed by LBR as
yarn-design-system-react-components+yarn-design-system-icons, treated as a webpack external so 10+ MFEs share one instance (big bundle-size win).
import { appendIconDefinitions } from 'yarn-design-system-icons';
import { Button } from 'yarn-design-system-react-components';
12 Packaging & serving — Docker + Nginx
Each MFE builds its static dist (webpack) plus the bundled common package, and is baked into an Nginx Docker image (Alpine Node base, e.g. alpine-3.23_node-24).
- Registry: Harbor
registry.tools.3stripes.net/ebtpc-rcs/lbr-microfe-*(mirrored to Artifactory). Tags viagit describe.
server {
listen {{ .Env.NGINX_PORT }}; # 8080
resolver coredns.kube-system;
root /usr/share/nginx/html;
gzip on;
location / {
try_files $uri /index.html; # SPA fallback
}
}
Logs go to stdout/stderr; daemon off for the container.
13 Kubernetes deployment — lbr-deployments (Kustomize)
Structure: bases + per-environment overlays.
kustomize/
bases/frontend/microfe-<domain>/
deploy.yaml # Deployment (Nginx)
service.yaml
kustomization.yaml
vpa.yaml # Vertical Pod Autoscaler
harbor-external-secret.yaml # pull secret
fortanix-secret-store.yaml # secrets manager
commonLabelTransformer.yaml
secrets/fortanix.yaml
overlays/lbr/<env>/aws/frontend/microfe-<domain>/
kustomization.yaml # references base + sets image newTag
environment.yaml # env patch
patch-service.yaml
patch-harbor-external-secret.yaml
configuration.yaml
prefixSuffixTransformer.yaml
prefixSuffixSecretTransformer.yaml
Environments: dev, sit, uat, pre, prod — each on the aws cluster.
Deployment hardening (deploy.yaml)
- Image
…/lbr-microfe-*:<tag>,imagePullPolicy: Always,replicas: 1,strategy: Recreate. - Security:
runAsNonRoot, uid/gid1000,readOnlyRootFilesystem,allowPrivilegeEscalation: false, drop ALL caps,seccompProfile: RuntimeDefault,fsGroup: 1000. - Probes: readiness
GET /health(8080); livenesstcpSocket; startupexec pgrep nginx. containerPort: 8080; env:CMO_NGINX_*,EXEC_ENVIRONMENT,INSTANA_AGENT_HOST/PORT(42699)/SERVICE_NAME.- Resources: limits cpu
250m/mem256Mi, requests cpu100m/mem256Mi. emptyDirvolumes for nginx/tmp/logs (root FS is read-only).
kustomization.yaml sets the image newTag per environment. Promotion = bump the tag (promote the same immutable image), not a rebuild.resources: [../../../../../../bases/frontend/microfe-artwork]
images:
- name: registry.tools.3stripes.net/ebtpc-rcs/lbr-microfe-artwork
newTag: lbr-dev
14 CI/CD as code — lbr-jenkins-dsl + Jenkinsfiles
14.1 · Job DSL generates the Jenkins jobs
lbr-jenkins-dsl contains seed scripts (seed_01_Folders.groovy, seed_01_Jobs.groovy) and per-app files under buildAndDeploy/ that create a multibranch pipeline per app:
List<Map> multibranchCIJobs = [[
name: getRootPrefixed('BuildAndDeploy/lbrMicrofeBasicPattern/build'),
displayName: 'Build and Deploy',
branchSources: [
github: [ repoOwner: 'bct-adidas', repository: 'lbr-frontend', credentialsId: 'github_lbr' ],
scriptPath: 'lbr-microfe-basic-pattern/Jenkinsfile'
]
]]
jenkinsJobMultibranchPipeline(multibranchCIJobs)
There are DSL files for every frontend MFE and every backend service — the whole LBR CI topology is code.
14.2 · The per-MFE Jenkinsfile
- frontend component: harbor credentials/registry,
imageName: ebtpc-rcs/lbr-microfe-<domain>,includePackages: 'common', Sonar. - k8s component: target repo
lbr-deployments.git,overlaySuffix: frontend/microfe-<domain>, Fortanix, kubeconfig. - Flow: checkout → install → lint (Biome) → test (Jest) → Sonar → webpack build (
remoteEntry.js) → Docker build → push to Harbor → update the Kustomize overlay image tag → deploy.
14.3 · Promotion pipeline
Environment promotion is a shared pipeline (com.adidas.cmo.pipeline.Promote) that re-applies the same built image to the next environment by updating its Kustomize overlay:
k8sMap.promoteFrom = params.promoteFrom
k8sMap.promoteTo = params.promoteTo
k8sMap.clusterTo = params.clusterTo
withNotifications { withFortanix { withKustomize(k8sMap) { withKustomizeEnv { promote(k8sMap) } } } }
The identical artifact flows dev → sit → uat → pre → prod by tag promotion (prod targets the master branch of the deployments repo).
15 End-to-end flows
15.1 · Build & deploy (one MFE)
graph LR
A["git push to lbr-frontend
(MFE + common)"] --> B["Jenkins multibranch job
(generated by lbr-jenkins-dsl)"]
B --> C["install → lint → test → Sonar"]
C --> D["webpack MF build
remoteEntry.js + chunks"]
D --> E["Docker build (Alpine Node + Nginx)"]
E --> F["push image → Harbor
ebtpc-rcs/lbr-microfe-*"]
F --> G["bump image tag in dev overlay
(lbr-deployments)"]
G --> H["Kustomize apply → K8s (AWS)
Nginx pod serves new bundle"]
H --> I["promote tag: sit → uat → pre → prod"]
15.2 · Runtime (user opens a domain screen)
- User loads the portal (React 18).
- Portal authenticates, loads feature flags + config (remotes, service paths, keys) via
ConfigService. BrowserRoutermatches the URL → mountsAppContainerfor the matching remote.HostProvider+useLoadRemote→host.loadRemote(name)fetches that MFE'sremoteEntry.json demand, cached for the session.- The shared
mfe-containerbuildsmfeProps(auth, routes, config, contexts) and mounts the remote in aRemoteErrorBoundary. - The remote (React 17) bootstraps providers, bridges the single
react-router-dom, runs itsMemoryRoutersynced to the host URL, initializes observability (Instana + Matomo), and renders. - The screen loads view preferences (grid columns/facets) and fetches domain data via React Query.
- Result: independently deployed MFE behaving as one seamless Creation Portal.
16 Key design decisions & trade-offs
| Decision | Why | Trade-off / risk |
|---|---|---|
Runtime Module Federation (not build-time shared) | Independent deploy; host & remotes decoupled; add MFEs via config | Runtime fetch cost (mitigated by ∞ React Query cache) |
| Custom m3po loader over raw MF | Clean React API, Suspense + RQ integration, testable | Maintain an internal package |
| React 18 host + React 17 MFEs | Teams upgrade independently; no lockstep | Must bridge shared concerns carefully |
Single react-router-dom via window | Avoid "two router contexts" bug; unified history | Everyone must align on one router version |
| Design system + grid theme as externals | 10+ MFEs share one instance → smaller bundles | Host must provide the exact expected version |
| Config-server-driven remotes | Add/toggle MFEs per environment without rebuilding the portal | Config correctness becomes operationally critical |
| Immutable image-tag promotion | Same artifact dev→prod; reproducible; fast rollback | Requires disciplined overlay management |
| Error boundary per remote | One broken MFE can't crash the shell | Must design good fallback UX |
Shared common + rcs-fe-common | No duplication; consistent platform behavior | Changes are high-blast-radius (extra care) |
| Per-user preferences in a service | Grid/filter state follows the user; built once | Extra network dependency per screen |
17 Performance story
- Profiled first (did not optimize blindly) using Webpack Bundle Analyzer + Rsdoctor.
- Tree shaking reduced the bundle from ~2.8 MB → ~1.6 MB.
- Route-level code splitting + lazy loading of heavy containers/deps (
React.lazy+import()behindSuspense). - MFE on-demand loading: the host never loads all apps up front — each remote and its routes load as on-demand chunks (
chunk-[name].[contenthash].js). - Caching: content-hashed immutable assets (long-lived cache), no-cache
index.html; React Query caches server responses. - Net result: ~50% page-load improvement.
- Next direction (roadmap): serve bundles from a CDN / edge — origin is Europe-only, so distant users pay latency.
18 Glossary / quick-reference cheat-sheet
Host shell
fe-microfrontend-portal, React 18, title "Creation Portal".
Loader
@m3po-mfe-kit/mfe-loader on @module-federation/runtime; HostProvider → createInstance; useLoadRemote (RQ, suspense, cache ∞); RemoteMicroFrontend → createRemoteRoot(node).render(mfeProps).
MFEs
~10 React 17 remotes in lbr-frontend: materials, colors, labels, hangtags, artwork, packaging, basicpattern, lasts, process, libraryselector. Each exposes { '.': './src/main.tsx' }, UMD, remoteEntry.js.
Config
ConfigService loads /frontend-config-service/portal/rcs-microfe-portal.json (prod remotes) + rcs-feature-flags.json.
Shared context
mfeProps = { baseUrl, url, configServerUrl, contexts, customConfig }.
Routing
Host BrowserRouter; MFE MemoryRouter; single react-router-dom bridged via window.ReactRouterDOM.
User prefs
ViewPreferencesProvider → user-preference-service via React Query.
Platform pkgs
rcs-fe-common (Turbo monorepo, @rcs-fe-common/*).
Design system
yarn-design-system (Lerna, independent versioning, safe-publish, webpack external).
Serving
Nginx (try_files $uri /index.html, port 8080) in Alpine Node image → Harbor ebtpc-rcs/lbr-microfe-*.
Deploy
lbr-deployments Kustomize bases + overlays lbr/{dev,sit,uat,pre,prod}/aws/frontend/microfe-*; hardened Nginx Deployment; tag-promotion.
CI/CD
lbr-jenkins-dsl Job DSL → Jenkins multibranch per MFE; build → Harbor → bump overlay tag → deploy; shared Promote pipeline.
Observability
Instana + Matomo, initialized per-MFE with package version.
Bundle perf
2.8 MB → 1.6 MB (tree shaking) + splitting/lazy + on-demand chunks ≈ 50% faster.