diff --git a/datahub-web-react/src/app/mfeframework/MFEConfigurableContainer.tsx b/datahub-web-react/src/app/mfeframework/MFEConfigurableContainer.tsx
new file mode 100644
index 00000000000000..6fe0be270901ce
--- /dev/null
+++ b/datahub-web-react/src/app/mfeframework/MFEConfigurableContainer.tsx
@@ -0,0 +1,7 @@
+import React from 'react';
+
+import { MFEConfig } from '@app/mfeframework/mfeConfigLoader';
+
+export const MFEBaseConfigurablePage = ({ config }: { config: MFEConfig }) => {
+ return
{config.label}
;
+};
diff --git a/datahub-web-react/src/app/mfeframework/READMEMFE.md b/datahub-web-react/src/app/mfeframework/READMEMFE.md
new file mode 100644
index 00000000000000..af4b726790b58c
--- /dev/null
+++ b/datahub-web-react/src/app/mfeframework/READMEMFE.md
@@ -0,0 +1,104 @@
+# Micro Frontend (MFE) Onboarding & Contributor Guide
+
+## Introduction
+
+Scalable onboarding of Micro Frontends (MFEs) in DataHub is achieved using a validated configuration schema.
+MFEs are declared in a canonical YAML file, validated at runtime, and dynamically integrated into the application’s navigation.
+With this approach, you can add, update, or remove MFEs by configuration only—**no manual code changes required**.
+
+## Setup & Usage
+
+1. **See `datahub-web-react/src/app/mfeframework` for all relevant code and configuration.**
+2. **Declare MFEs in `mfe.config.yaml` using the schema below.**
+3. **Navigation items are automatically added based on your YAML settings—no manual coding needed\***
+4. **Run locally:** Point `remoteEntry` to your local dev server.
+5. **Deploy:** No code changes required for new MFEs—just update the config! --> (`mfe.config.yaml`)
+
+## Validation & Troubleshooting
+
+- **Validation logic** `mfeConfigLoader.tsx` handles validation of `mfe.config.yaml`
+ - Invalid entries are marked with `invalid: true` and an array of `errorMessages`, and are skipped in navigation and routing.
+ - **Missing required fields:** Loader will log which fields are missing.
+ - **Invalid types:** Loader will log type errors (e.g., non-boolean flags).
+ - **No config found:** Loader will warn if the YAML file is missing or empty.
+
+**Example errors:**
+
+```
+[MFE Loader] flags.showInNav must be boolean
+[MFE Loader] flags must be an object
+[MFE Loader] path must be a string starting with "/"
+```
+
+## Features
+
+- **Config-Driven Onboarding:** Add MFEs by editing a YAML file.
+- **Validation:** Ensures all required fields are present and correctly typed.
+- **Dynamic Navigation:** Automatically generates navigation items for enabled MFEs.
+- **Robust Error Handling:** Logs clear error messages for missing or invalid configuration.
+
+## Configuration Schema
+
+Declare your MFEs in a YAML file (`mfe.config.yaml`) using the following example structure:
+
+The following schema defines all micro frontends available in the application.
+Each field is **mandatory** and must conform to the specified type.
+
+- **subNavigationMode:** boolean (true/false)
+- **microFrontends:** list of MFE objects (see below)
+
+Each MFE object (microFrontends) must include:
+
+- **id:** string (unique identifier for the MFE)
+- **label:** string (display name in navigation)
+- **path:** string (route path, must start with '/')
+- **remoteEntry:** string (URL to the remoteEntry.js file)
+- **module:** string (module name to mount, e.g. 'appName/mount')
+- **flags:** object (see below)
+ - **enabled:** boolean (true to enable, false to disable)
+ - **showInNav:** boolean (true to show in navigation)
+- **permissions:** array of strings (required permissions for access)
+- **navIcon:** string (icon name from "@phosphor-icons/react", e.g. 'Gear')
+
+**Example icon names:** Gear, Globe, Acorn, Airplane, Alarm
+**See: https://github.com/phosphor-icons/react/tree/master/src/csr**
+
+```yaml
+subNavigationMode: false
+microFrontends:
+ - id: example-1
+ label: Example MFE Yaml Item
+ path: /example-mfe-item
+ remoteEntry: http://example.com/remoteEntry.js
+ module: exampleApplication/mount
+ flags:
+ enabled: true
+ showInNav: true
+ permissions:
+ - example_permission
+ navIcon: Gear
+```
+
+## Dynamic Navigation Integration
+
+Sidebar navigation items are generated dynamically from the validated MFE config.
+You may toggle between using Sub Navigation or not:
+
+- See above schema for: **subNavigationMode:** boolean (true/false)
+ - If subNavigationMode is enabled, the navigation bar will render each MFE into a scrollable list
+ - If subNavigationMode is disabled, each MFE will appear in navigation bar in order of declaration of yaml
+
+## Common Pitfalls
+
+- **All required fields must be present and valid.**
+- **Path must start with `/`.**
+- **RemoteEntry must be accessible.**
+- **Icon names must be valid or provide a valid image path.**
+- **Permissions array must not be empty.**
+- **Local server must be running for local development.**
+- **Invalid entries are logged and skipped.**
+
+## Next Steps
+
+- **Deployment:** No code changes required for new MFEs—just update the config!
+ _(Production deployment steps are still being finalized. Please check back for updates.)_
diff --git a/datahub-web-react/src/app/mfeframework/_tests_/mfeConfigLoader.test.tsx b/datahub-web-react/src/app/mfeframework/_tests_/mfeConfigLoader.test.tsx
new file mode 100644
index 00000000000000..f614de5420f7aa
--- /dev/null
+++ b/datahub-web-react/src/app/mfeframework/_tests_/mfeConfigLoader.test.tsx
@@ -0,0 +1,271 @@
+import { render } from '@testing-library/react';
+import { renderHook } from '@testing-library/react-hooks';
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+/**
+ * Test Isolation and Mocking Strategy
+ * -----------------------------------
+ * Each test in this file sets up its own mocks for YAML parsing and file imports
+ * using inline helper functions. This ensures:
+ * - Complete isolation between tests: no mock leakage or shared state.
+ * - Deterministic behavior: each test controls exactly what the YAML parser and file return.
+ * - No external file reads: all YAML content is mocked, never loaded from disk.
+ * - Flexible mocking: UI dependencies and config variations are easily handled per test.
+ * This approach keeps tests robust, maintainable, and focused on the intended scenario.
+ */
+
+function mockYamlLoad(returnValue: any) {
+ vi.doMock('js-yaml', () => ({
+ default: { load: vi.fn().mockReturnValue(returnValue) },
+ }));
+}
+
+function mockYamlLoadThrows(error: Error) {
+ vi.doMock('js-yaml', () => ({
+ default: {
+ load: vi.fn().mockImplementation(() => {
+ throw error;
+ }),
+ },
+ }));
+}
+
+function mockYamlFile(content: string) {
+ vi.doMock('@app/mfeframework/mfe.config.yaml?raw', () => ({ default: content }));
+}
+
+function mockReactRouter() {
+ vi.doMock('react-router', () => ({
+ Route: ({ path, render: renderProp }: any) => (
+
+ Route: {path} - {renderProp()}
+
+ ),
+ }));
+}
+
+function mockMFEBasePage() {
+ vi.doMock('@app/mfeframework/MFEConfigurableContainer', () => ({
+ MFEBaseConfigurablePage: ({ config }: { config: any }) => MFE: {config.module}
,
+ }));
+}
+
+const validParsedYaml = {
+ subNavigationMode: false,
+ microFrontends: [
+ {
+ id: 'example-1',
+ label: 'Example MFE Yaml Item',
+ path: '/example-mfe-item',
+ remoteEntry: 'http://example.com/remoteEntry.js',
+ module: 'exampleApplication/mount',
+ flags: { enabled: true, showInNav: true },
+ permissions: ['example_permission'],
+ navIcon: 'Gear',
+ },
+ {
+ id: 'another',
+ label: 'Another from Yaml',
+ path: '/another-mfe',
+ remoteEntry: 'http://localhost:9111/remoteEntry.js',
+ module: 'anotherRemoteModule/mount',
+ flags: { enabled: true, showInNav: false },
+ permissions: ['example_permission'],
+ navIcon: 'Globe',
+ },
+ ],
+};
+
+describe('mfeConfigLoader', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ // Default YAML file mock for most tests; override in test if needed
+ mockYamlFile('irrelevant');
+ });
+
+ it('loadMFEConfigFromYAML parses valid YAML and validates config', async () => {
+ mockYamlLoad(validParsedYaml);
+ const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader');
+ const result = loadMFEConfigFromYAML('irrelevant');
+ expect(result.subNavigationMode).toBe(false);
+ expect(result.microFrontends.length).toBe(2);
+ expect(result.microFrontends[0]).toMatchObject(validParsedYaml.microFrontends[0]);
+ expect(result.microFrontends[1]).toMatchObject(validParsedYaml.microFrontends[1]);
+ });
+
+ it('loadMFEConfigFromYAML marks missing required fields as invalid and collects all errors', async () => {
+ mockYamlLoad({
+ subNavigationMode: false,
+ microFrontends: [
+ {
+ // id missing, navIcon missing
+ label: 'Missing ID and navIcon',
+ path: '/missing-id',
+ remoteEntry: 'remoteEntry.js',
+ module: 'MissingIdModule',
+ flags: { enabled: true, showInNav: false },
+ permissions: ['perm1'],
+ },
+ ],
+ });
+ const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader');
+ const result = loadMFEConfigFromYAML('irrelevant');
+ const mfe = result.microFrontends[0];
+ if ('invalid' in mfe && mfe.invalid) {
+ expect(Array.isArray((mfe as any).errorMessages)).toBe(true);
+ expect((mfe as any).errorMessages).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining('Missing required field: id'),
+ expect.stringContaining('Missing required field: navIcon'),
+ ]),
+ );
+ }
+ });
+
+ it('loadMFEConfigFromYAML marks multiple errors for a single MFE', async () => {
+ mockYamlLoad({
+ subNavigationMode: false,
+ microFrontends: [
+ {
+ id: 'bad-flags',
+ label: 'Bad Flags',
+ path: '/bad-flags',
+ remoteEntry: 'remoteEntry.js',
+ module: 123, // not a string
+ flags: { enabled: 'yes', showInNav: false }, // enabled not boolean
+ permissions: 'perm1', // not array
+ navIcon: 123, // not a string
+ },
+ ],
+ });
+ const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader');
+ const result = loadMFEConfigFromYAML('irrelevant');
+ const mfe = result.microFrontends[0];
+ if ('invalid' in mfe && mfe.invalid) {
+ expect(Array.isArray((mfe as any).errorMessages)).toBe(true);
+ expect((mfe as any).errorMessages).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining('module must be a string'),
+ expect.stringContaining('flags.enabled must be boolean'),
+ expect.stringContaining('permissions must be a non-empty array of strings'),
+ expect.stringContaining('navIcon must be a non-empty string'),
+ ]),
+ );
+ }
+ });
+
+ it('loadMFEConfigFromYAML marks empty permissions as invalid', async () => {
+ mockYamlLoad({
+ subNavigationMode: false,
+ microFrontends: [
+ {
+ id: 'empty-permissions',
+ label: 'Empty Permissions',
+ path: '/empty-permissions',
+ remoteEntry: 'remoteEntry.js',
+ module: 'EmptyPermissionsModule',
+ flags: { enabled: true, showInNav: false },
+ permissions: [],
+ navIcon: 'Gear',
+ },
+ ],
+ });
+ const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader');
+ const result = loadMFEConfigFromYAML('irrelevant');
+ const mfe = result.microFrontends[0];
+ if ('invalid' in mfe && mfe.invalid) {
+ expect(Array.isArray((mfe as any).errorMessages)).toBe(true);
+ expect((mfe as any).errorMessages).toEqual(
+ expect.arrayContaining([expect.stringContaining('permissions must be a non-empty array of strings')]),
+ );
+ }
+ });
+
+ it('loadMFEConfigFromYAML throws if microFrontends is missing', async () => {
+ mockYamlLoad({});
+ const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader');
+ expect(() => loadMFEConfigFromYAML('irrelevant')).toThrow(
+ '[MFE Loader] Invalid YAML: missing microFrontends array',
+ );
+ });
+
+ it('loadMFEConfigFromYAML throws if YAML parsing fails', async () => {
+ mockYamlLoadThrows(new Error('bad yaml'));
+ const { loadMFEConfigFromYAML } = await import('../mfeConfigLoader');
+ expect(() => loadMFEConfigFromYAML('irrelevant')).toThrow('bad yaml');
+ });
+
+ it('useMFEConfigFromYAML returns config if YAML is valid', async () => {
+ mockYamlLoad({
+ subNavigationMode: false,
+ microFrontends: [
+ {
+ id: 'example-1',
+ label: 'Example MFE Yaml Item',
+ path: '/example-mfe-item',
+ remoteEntry: 'http://example.com/remoteEntry.js',
+ module: 'exampleApplication/mount',
+ flags: { enabled: true, showInNav: true },
+ permissions: ['example_permission'],
+ navIcon: 'Gear',
+ },
+ ],
+ });
+ const { useMFEConfigFromYAML } = await import('../mfeConfigLoader');
+ const { result } = renderHook(() => useMFEConfigFromYAML());
+ expect(result.current?.microFrontends[0]).toMatchObject({
+ id: 'example-1',
+ label: 'Example MFE Yaml Item',
+ path: '/example-mfe-item',
+ remoteEntry: 'http://example.com/remoteEntry.js',
+ module: 'exampleApplication/mount',
+ flags: { enabled: true, showInNav: true },
+ permissions: ['example_permission'],
+ navIcon: 'Gear',
+ });
+ });
+
+ it('useMFEConfigFromYAML returns null if mfeYamlRaw is empty', async () => {
+ mockYamlFile('');
+ mockYamlLoad(null);
+ const { useMFEConfigFromYAML } = await import('../mfeConfigLoader');
+ const { result } = renderHook(() => useMFEConfigFromYAML());
+ expect(result.current).toBeNull();
+ });
+
+ it('useMFEConfigFromYAML returns null if YAML is invalid', async () => {
+ mockYamlLoadThrows(new Error('bad yaml'));
+ const { useMFEConfigFromYAML } = await import('../mfeConfigLoader');
+ const { result } = renderHook(() => useMFEConfigFromYAML());
+ expect(result.current).toBeNull();
+ });
+
+ it('useDynamicRoutes returns empty array if no config', async () => {
+ mockYamlLoad(null);
+ const { useDynamicRoutes } = await import('../mfeConfigLoader');
+ const { result } = renderHook(() => useDynamicRoutes());
+ expect(result.current).toEqual([]);
+ });
+
+ it('useDynamicRoutes returns Route elements for each MFE', async () => {
+ mockYamlLoad(validParsedYaml);
+ const { useDynamicRoutes } = await import('../mfeConfigLoader');
+ const { result } = renderHook(() => useDynamicRoutes());
+ expect(result.current).toHaveLength(2);
+ expect(result.current[0].props.path).toBe('/example-mfe-item');
+ expect(result.current[1].props.path).toBe('/another-mfe');
+ });
+
+ it('MFERoutes renders the dynamic routes', async () => {
+ mockYamlLoad(validParsedYaml);
+ mockReactRouter();
+ mockMFEBasePage();
+ const { MFERoutes } = await import('../mfeConfigLoader');
+ const { container } = render();
+ expect(container.textContent).toContain('Route: /example-mfe-item');
+ expect(container.textContent).toContain('Route: /another-mfe');
+ expect(container.textContent).toContain('MFE: exampleApplication/mount');
+ expect(container.textContent).toContain('MFE: anotherRemoteModule/mount');
+ });
+});
diff --git a/datahub-web-react/src/app/mfeframework/mfe.config.yaml b/datahub-web-react/src/app/mfeframework/mfe.config.yaml
new file mode 100644
index 00000000000000..e780782575d777
--- /dev/null
+++ b/datahub-web-react/src/app/mfeframework/mfe.config.yaml
@@ -0,0 +1,17 @@
+subNavigationMode: false
+microFrontends: []
+# # sample entry, uncomment and modify as needed
+# - id: example-1
+# label: Example MFE Yaml Item
+# path: /example-mfe-item
+# remoteEntry: http://example.com/remoteEntry.js
+# module: exampleApplication/mount
+# #flags is enforced to be a list with two boolean entries
+# flags:
+# enabled: true
+# showInNav: true
+# # permissions is enforced to be an array
+# permissions:
+# - example_permission
+# # navIcon value is name of any icon in "@phosphor-icons/react" that you decide to use for your mfe, can be found in https://github.com/phosphor-icons/react/tree/master/src/csr
+# navIcon: Gear
diff --git a/datahub-web-react/src/app/mfeframework/mfeConfigLoader.tsx b/datahub-web-react/src/app/mfeframework/mfeConfigLoader.tsx
new file mode 100644
index 00000000000000..7873f43e2180e8
--- /dev/null
+++ b/datahub-web-react/src/app/mfeframework/mfeConfigLoader.tsx
@@ -0,0 +1,179 @@
+import yaml from 'js-yaml';
+import React, { useMemo } from 'react';
+import { Route } from 'react-router';
+
+import { MFEBaseConfigurablePage } from '@app/mfeframework/MFEConfigurableContainer';
+// Vite's ?raw import lets you import the YAML file as a string
+import mfeYamlRaw from '@app/mfeframework/mfe.config.yaml?raw';
+
+export interface MFEFlags {
+ enabled: boolean;
+ showInNav: boolean;
+}
+
+// MFEConfig: Type for a valid micro frontend config entry.
+export interface MFEConfig {
+ id: string;
+ label: string;
+ path: string;
+ remoteEntry: string;
+ module: string;
+ flags: MFEFlags;
+ permissions: string[];
+ navIcon: string;
+}
+
+/**
+ * InvalidMFEConfig: Type for an invalid micro frontend config entry.
+ * - invalid: true
+ * - errorMessages: array of validation errors for this entry
+ * - id: optional, for easier debugging/logging
+ * - [key: string]: any; allows for partial/invalid configs
+ */
+export interface InvalidMFEConfig {
+ invalid: true;
+ errorMessages: string[];
+ id?: string;
+ [key: string]: any;
+}
+
+// MFEConfigEntry: Union type for either a valid or invalid config entry.
+export type MFEConfigEntry = MFEConfig | InvalidMFEConfig;
+
+// MFESchema: The overall config schema, with a union array for microFrontends.
+export interface MFESchema {
+ subNavigationMode: boolean;
+ microFrontends: MFEConfigEntry[];
+}
+
+// SPECIAL NOTE: 'permissions' will be implemented in later sprints. Keeping it as required and subsequently validated so we do not forget.
+const REQUIRED_FIELDS: (keyof MFEConfig)[] = [
+ 'id',
+ 'label',
+ 'path',
+ 'remoteEntry',
+ 'module',
+ 'flags',
+ 'permissions',
+ 'navIcon',
+];
+
+/**
+ * validateMFEConfig:
+ * - Validates a single micro frontend config entry.
+ * - Collects all validation errors for the entry.
+ * - Returns a valid MFEConfig if no errors, otherwise returns InvalidMFEConfig with all error messages.
+ * - This allows the loader to keep all entries (valid and invalid) and not throw on the first error.
+ */
+export function validateMFEConfig(config: any): MFEConfigEntry {
+ const errors: string[] = [];
+
+ REQUIRED_FIELDS.forEach((field) => {
+ if (config[field] === undefined || config[field] === null) {
+ errors.push(`[MFE Loader] Missing required field: ${field}`);
+ }
+ });
+ if (typeof config.id !== 'string') errors.push('[MFE Loader] id must be a string');
+ if (typeof config.label !== 'string') errors.push('[MFE Loader] label must be a string');
+ if (typeof config.path !== 'string' || !config.path.startsWith('/'))
+ errors.push('[MFE Loader] path must be a string starting with "/"');
+ if (typeof config.remoteEntry !== 'string') errors.push('[MFE Loader] remoteEntry must be a string');
+ if (typeof config.module !== 'string') errors.push('[MFE Loader] module must be a string');
+ if (typeof config.flags !== 'object' || config.flags === null) errors.push('[MFE Loader] flags must be an object');
+ if (config.flags) {
+ if (typeof config.flags.enabled !== 'boolean') errors.push('[MFE Loader] flags.enabled must be boolean');
+ if (typeof config.flags.showInNav !== 'boolean') errors.push('[MFE Loader] flags.showInNav must be boolean');
+ }
+ if (
+ !Array.isArray(config.permissions) ||
+ config.permissions.length === 0 ||
+ !config.permissions.every((p) => typeof p === 'string')
+ ) {
+ errors.push('[MFE Loader] permissions must be a non-empty array of strings');
+ }
+ if (typeof config.navIcon !== 'string' || !config.navIcon.length) {
+ errors.push('[MFE Loader] navIcon must be a non-empty string');
+ }
+
+ // If any errors, return as InvalidMFEConfig (with all errors collected)
+ if (errors.length > 0) {
+ return {
+ ...config,
+ invalid: true,
+ errorMessages: errors,
+ };
+ }
+ // Otherwise, return as valid MFEConfig
+ return config as MFEConfig;
+}
+
+/**
+ * loadMFEConfigFromYAML:
+ * - Loads and parses the YAML config string.
+ * - Validates each micro frontend entry, collecting errors but not throwing for individual entries.
+ * - Returns the parsed schema with both valid and invalid entries.
+ * - Throws only if the overall YAML is malformed or missing the microFrontends array.
+ */
+export function loadMFEConfigFromYAML(yamlString: string): MFESchema {
+ try {
+ console.log('[MFE Loader] Raw YAML:', yamlString);
+ const parsed = yaml.load(yamlString) as MFESchema;
+ // console.log('[MFE Loader] Parsed YAML config:', parsed);
+ if (!parsed || !Array.isArray(parsed.microFrontends)) {
+ console.error('[MFE Loader] Invalid YAML: missing microFrontends array:', parsed);
+ throw new Error('[MFE Loader] Invalid YAML: missing microFrontends array');
+ }
+ // Validate each entry, keeping both valid and invalid ones
+ parsed.microFrontends = parsed.microFrontends.map(validateMFEConfig);
+ return parsed;
+ } catch (e) {
+ console.error('[MFE Loader] Error parsing YAML:', e);
+ throw e;
+ }
+}
+
+export function useMFEConfigFromYAML(): MFESchema | null {
+ return useMemo(() => {
+ try {
+ if (!mfeYamlRaw) {
+ console.warn('[MFE Loader] No YAML config found');
+ return null;
+ }
+ const config = loadMFEConfigFromYAML(mfeYamlRaw);
+ if (config) {
+ console.log('[MFE Loader] useMFEConfigFromYAML loaded:', config);
+ }
+ return config;
+ } catch (e) {
+ console.error('[MFE Loader] Config error:', e);
+ return null; // <-- Return null so the app can continue rendering
+ }
+ }, []);
+}
+
+export function useDynamicRoutes(): JSX.Element[] {
+ const mfeConfig = useMFEConfigFromYAML();
+
+ // Type guard to narrow MFEConfigEntry to MFEConfig
+ const isValidMFEConfig = (entry: MFEConfigEntry): entry is MFEConfig => !('invalid' in entry && entry.invalid);
+
+ return useMemo(() => {
+ if (!mfeConfig) {
+ console.warn('[DynamicRoute] No MFE config available');
+ return [];
+ }
+ console.log('[DynamicRoute] MFE Config:', mfeConfig);
+ return mfeConfig.microFrontends
+ .filter(isValidMFEConfig)
+ .map((mfe) => (
+ } />
+ ));
+ }, [mfeConfig]);
+}
+
+// Constant to store the dynamic routes hook
+export const MFERoutes = () => {
+ const routes = useDynamicRoutes();
+ console.log('[DynamicRoute] Generated Routes:', routes);
+ return <>{routes}>;
+};