Micro frontends prometem times deployando sozinhos. Na prática, o que costuma travar isso não é a divisão do código, é a composição: como três aplicações React, buildadas e publicadas separadamente, viram uma tela só para o usuário sem virar três bundles de React na mesma página.

Module Federation é a resposta mais direta a esse problema. Este post explica o que ele é, como funciona por dentro e monta um exemplo real: um shell (host) que carrega dois remotes, catalog e cart, em runtime.

O que é Module Federation

Module Federation é um mecanismo de bundler que permite a uma aplicação carregar módulos de outra aplicação em runtime, sem que essa outra aplicação seja uma dependência instalada via npm. Nasceu no Webpack 5 e hoje existe como projeto independente (Module Federation 2.0), com plugins para Webpack, Rspack, Rsbuild e Vite.

Três termos definem todo o modelo:

A diferença para as alternativas conhecidas está em quando a composição acontece:

AbordagemComposição emDeploy independenteCusto principal
Pacote npmbuild timenão — precisa republicar o hostacoplamento de versão
iframeruntimesimisolamento excessivo (estado, rota, UX)
Module Federationruntimesimcontrato entre times e gestão de versões

Com pacote npm, atualizar o cart exige rebuildar e redeployar o host. Com Module Federation, o cart publica um novo remoteEntry.js e o host passa a carregar a versão nova no próximo acesso.

Como funciona por dentro

Cada remote gera, no build, um arquivo de entrada (remoteEntry.js) que funciona como um catálogo: lista o que o remote expõe e como carregar cada pedaço. O host, ao encontrar um import('catalog/ProductList'), não resolve isso no build — ele busca o remoteEntry.js do catalog via HTTP, pede o módulo e o executa.

Diagrama de sequência: o host inicializa o share scope, busca o remoteEntry.js de cada remote e carrega os módulos expostos

O ponto sutil é o share scope. Antes de qualquer remote ser executado, host e remotes registram no mesmo escopo as dependências que declararam como shared. Quando o catalog precisa de React, ele consulta o escopo: se já existe uma versão compatível registrada, usa essa em vez de carregar a própria.

É isso que impede três cópias de React. E é também a principal fonte de bug em projetos com Module Federation — mais sobre isso na seção de armadilhas.

Mão na massa: host + dois remotes

O exemplo usa React 18, Vite e o plugin oficial @module-federation/vite. A estrutura é um monorepo simples, cada app com seu próprio package.json:

mf-demo/
  host/       → shell, porta 3000
  catalog/    → remote, porta 3001
  cart/       → remote, porta 3002

1. Criar as três aplicações

mkdir mf-demo && cd mf-demo

npm create vite@latest host -- --template react-ts
npm create vite@latest catalog -- --template react-ts
npm create vite@latest cart -- --template react-ts

Em cada uma, instale as dependências e o plugin:

cd host && npm install && npm install -D @module-federation/vite
cd ../catalog && npm install && npm install -D @module-federation/vite
cd ../cart && npm install && npm install -D @module-federation/vite

2. Configurar o remote catalog

Primeiro o componente que será exposto:

// catalog/src/ProductList.tsx
const products = [
  { id: 1, name: 'Teclado mecânico' },
  { id: 2, name: 'Monitor 27"' },
  { id: 3, name: 'Mouse ergonômico' },
];

export default function ProductList() {
  return (
    <section>
      <h2>Catálogo</h2>
      <ul>
        {products.map((p) => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </section>
  );
}

Agora o vite.config.ts, onde a federação acontece:

// catalog/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

export default defineConfig({
  plugins: [
    federation({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/ProductList.tsx',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
    react(),
  ],
  server: { port: 3001 },
  build: { target: 'chrome89' },
});

Três campos importam aqui: name (identificador único do remote), exposes (o que fica público — a chave ./ProductList é o contrato) e shared (o que não deve ser duplicado).

O build.target mais alto existe porque o plugin usa top-level await no código gerado.

3. Configurar o remote cart

Mesma ideia, outro componente e outra porta:

// cart/src/CartWidget.tsx
import { useState } from 'react';

export default function CartWidget() {
  const [items, setItems] = useState(0);

  return (
    <aside>
      <h2>Carrinho ({items})</h2>
      <button onClick={() => setItems((n) => n + 1)}>Adicionar item</button>
    </aside>
  );
}
// cart/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

export default defineConfig({
  plugins: [
    federation({
      name: 'cart',
      filename: 'remoteEntry.js',
      exposes: {
        './CartWidget': './src/CartWidget.tsx',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
    react(),
  ],
  server: { port: 3002 },
  build: { target: 'chrome89' },
});

4. Configurar o host

O host declara onde encontrar cada remote. O formato é nome, URL do remoteEntry.js e o tipo do módulo:

// host/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

export default defineConfig({
  plugins: [
    federation({
      name: 'host',
      remotes: {
        catalog: {
          type: 'module',
          name: 'catalog',
          entry: 'http://localhost:3001/remoteEntry.js',
        },
        cart: {
          type: 'module',
          name: 'cart',
          entry: 'http://localhost:3002/remoteEntry.js',
        },
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
    react(),
  ],
  server: { port: 3000 },
  build: { target: 'chrome89' },
});

Consumir os remotes é um import() dinâmico com React.lazy, como qualquer outro code splitting:

// host/src/App.tsx
import { lazy, Suspense } from 'react';

const ProductList = lazy(() => import('catalog/ProductList'));
const CartWidget = lazy(() => import('cart/CartWidget'));

export default function App() {
  return (
    <main>
      <h1>Loja (host)</h1>
      <Suspense fallback={<p>Carregando catálogo…</p>}>
        <ProductList />
      </Suspense>
      <Suspense fallback={<p>Carregando carrinho…</p>}>
        <CartWidget />
      </Suspense>
    </main>
  );
}

Como o TypeScript não conhece módulos que só existem em runtime, declare-os para o compilador parar de reclamar:

// host/src/remotes.d.ts
declare module 'catalog/ProductList' {
  const ProductList: React.ComponentType;
  export default ProductList;
}

declare module 'cart/CartWidget' {
  const CartWidget: React.ComponentType;
  export default CartWidget;
}

5. Rodar

Em três terminais:

cd catalog && npm run dev   # :3001
cd cart && npm run dev      # :3002
cd host && npm run dev      # :3000

Abra http://localhost:3000. O host renderiza o catálogo e o carrinho, cada um vindo de um servidor diferente. Aba Network do navegador: você vê os dois remoteEntry.js sendo buscados, e uma cópia de React.

Para provar a independência de deploy, altere o texto do CartWidget, salve e recarregue o host. O host não foi rebuildado nem reiniciado — só o remote mudou.

Arquitetura resultante

Arquitetura do exemplo: host na porta 3000 carregando catalog e cart, com react e react-dom compartilhados como singleton

Armadilhas que aparecem em projeto real

Duas cópias de React. Se singleton: true faltar em algum lado, ou as versões forem incompatíveis com requiredVersion, cada app carrega o próprio React. O sintoma clássico é o erro de hooks inválidos (Invalid hook call), porque o componente do remote usa uma instância de React diferente da que o host renderizou. Ao debugar, comece sempre pelo shared.

Contrato do exposes é API pública. Renomear ./CartWidget quebra todo host que o consome, e isso só aparece em runtime. Trate as chaves expostas como versionamento de API: adicione, deprecie, só depois remova.

Remote fora do ar derrubaria a página. Como a composição é em runtime, se o cart estiver indisponível o import() falha. Envolva cada remote em um Error Boundary com fallback — o resto da tela precisa continuar funcionando.

// host/src/RemoteBoundary.tsx
import { Component, type ReactNode } from 'react';

type Props = { children: ReactNode; fallback: ReactNode };

export class RemoteBoundary extends Component<Props, { failed: boolean }> {
  state = { failed: false };

  static getDerivedStateFromError() {
    return { failed: true };
  }

  render() {
    return this.state.failed ? this.props.fallback : this.props.children;
  }
}

URLs hardcoded. No exemplo, os remotes apontam para localhost. Em produção, essas URLs precisam vir de configuração por ambiente. O runtime do Module Federation oferece registerRemotes para registrar remotes dinamicamente (por exemplo, a partir de um manifesto buscado no boot da aplicação), em vez de fixar tudo no build do host.

Estado compartilhado entre remotes. Compartilhar código é fácil; compartilhar estado é onde o acoplamento volta. Prefira comunicação por eventos do navegador ou props vindas do host, e evite um store global que todos os remotes importam — isso recria o monólito distribuído.

Quando faz sentido usar

Module Federation paga o custo quando há times distintos com ciclos de deploy distintos trabalhando na mesma interface. Nesse cenário, o ganho de autonomia supera o custo de manter contratos e gerenciar versões compartilhadas.

Para um time único com um produto único, é complexidade sem retorno: um monorepo com pacotes internos resolve com muito menos peças móveis. E se a necessidade for só dividir o código em partes carregadas sob demanda, React.lazy com code splitting comum já basta.

O que fica

Module Federation muda o ponto de composição da aplicação: sai do build e vai para o runtime. Isso entrega deploy independente de verdade, mas transfere a responsabilidade para três decisões que precisam ser explícitas — o que cada remote expõe, o que é compartilhado como singleton, e o que acontece quando um remote falha.

Se essas três estiverem resolvidas, adicionar um quarto remote é copiar o vite.config.ts do cart. Se não estiverem, o primeiro Invalid hook call em produção vai cobrar a conta.

Referências