Building Micro-Frontends with Module Federation

Last Modified: January 1, 2025

Micro-frontends have revolutionized how we build large-scale web applications, allowing teams to work independently while maintaining a cohesive user experience. Module Federation, introduced in Webpack 5, has become the go-to solution for implementing micro-frontend architectures. Let's explore how to build and deploy micro-frontends effectively.

Understanding Module Federation

Module Federation enables multiple independent applications to share code and dependencies in real-time. This powerful feature allows you to:

  • Load remote modules dynamically
  • Share dependencies between applications
  • Deploy applications independently
  • Scale development across teams

Setting Up Your First Federated Module

First, configure your host application:

// webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      filename: 'remoteEntry.js',
      remotes: {
        app1: 'app1@http://localhost:3001/remoteEntry.js',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
};

Then, set up your remote application:

// Remote app webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/Button',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
};

Consuming Federated Modules

Import and use remote modules in your host application:

// App.js
const RemoteButton = React.lazy(() => import('app1/Button'));

function App() {
  return (
    <div>
      <React.Suspense fallback="Loading...">
        <RemoteButton />
      </React.Suspense>
    </div>
  );
}

Best Practices for Micro-Frontend Architecture

1. Define Clear Boundaries

Each micro-frontend should:

  • Own its route
  • Manage its state independently
  • Handle its own data fetching
  • Be independently deployable

2. Share Common Dependencies

Optimize bundle size by sharing common dependencies:

shared: {
  react: {
    singleton: true,
    requiredVersion: deps.react,
  },
  'react-dom': {
    singleton: true,
    requiredVersion: deps['react-dom'],
  },
}

3. Implement Version Control

Use semantic versioning for your exposed modules:

exposes: {
  './Button': {
    import: './src/Button',
    version: '1.0.0',
  },
}

Error Handling and Fallbacks

Implement robust error boundaries:

class ErrorBoundary extends React.Component {
  state = { hasError: false }

  static getDerivedStateFromError(error) {
    return { hasError: true }
  }

  render() {
    if (this.state.hasError) {
      return <div>Something went wrong loading the remote component</div>
    }
    return this.props.children
  }
}

Performance Optimization

1. Implement Caching Strategies

Configure effective caching for remote entries:

output: {
  publicPath: 'auto',
  clean: true,
  filename: '[name].[contenthash].js',
}

2. Optimize Loading Patterns

Use dynamic imports strategically:

const loadComponent = async () => {
  await loadScript('http://localhost:3001/remoteEntry.js');
  const component = await import('app1/Button');
  return component;
}

Monitoring and Analytics

Implement monitoring for your micro-frontends:

  • Track loading times of remote modules
  • Monitor chunk sizes and loading patterns
  • Set up error tracking for remote module failures

Security Considerations

1. Content Security Policy

Configure appropriate CSP headers:

Content-Security-Policy: script-src 'self' https://trusted-domain.com

2. CORS Configuration

Set up proper CORS policies for remote module loading:

app.use(cors({
  origin: ['https://app1.example.com', 'https://app2.example.com'],
  credentials: true
}));

Deployment Strategies

1. Progressive Rollout

Implement feature flags for new micro-frontends:

if (featureFlags.newMicroFrontend) {
  const NewFeature = React.lazy(() => import('app2/NewFeature'));
  return <NewFeature />;
}

2. Blue-Green Deployment

Use environment variables for remote URLs:

remotes: {
  app1: `app1@${process.env.APP1_URL}/remoteEntry.js`,
}

Testing Strategies

1. Integration Testing

Test the integration between micro-frontends:

describe('Micro-frontend Integration', () => {
  it('loads remote button successfully', async () => {
    render(<RemoteButton />);
    await waitFor(() => {
      expect(screen.getByRole('button')).toBeInTheDocument();
    });
  });
});

2. End-to-End Testing

Set up comprehensive E2E tests:

describe('E2E Flow', () => {
  it('completes user journey across micro-frontends', () => {
    cy.visit('/');
    cy.get('[data-testid="remote-button"]').click();
    cy.url().should('include', '/remote-page');
  });
});

Conclusion

Module Federation has transformed how we build micro-frontends, enabling truly distributed frontend architectures. By following these best practices and implementation patterns, you can create scalable, maintainable, and performant micro-frontend applications.

Remember to:

  • Start with clear architectural boundaries
  • Implement robust error handling
  • Optimize performance and loading patterns
  • Maintain strong security practices
  • Use appropriate testing strategies

As micro-frontend architectures continue to evolve, staying updated with the latest Module Federation features and best practices will be crucial for building successful applications.