Vinicius Aguiar
Arquitetura

A página tinha 514 linhas. A moldura, 2.560.

25 de set de 2026 · 6 min de leitura

No post anterior eu coloquei um app Angular dentro de uma página deste site: um micro frontend composto dentro da página. Esse é um modelo. Outro é mais simples de descrever: dividir por rota. Um caminho do site pertence a outro app, com build e deploy próprios, e o domínio continua o mesmo.

Então fiz isso aqui. A página Ferramentas agora é servida por um segundo app Next.js. Abra o DevTools nela e a resposta traz um header que este site não manda em nenhum outro lugar: x-zone: uses.

A página era a parte barata

A página em si tem 514 linhas: uma lista de ferramentas em cinco idiomas. Levar isso para outro app deveria ser copiar e colar.

Não é, porque a página não vive sozinha. Ela fica dentro da sidebar, do header do mobile, do footer, do atalho de busca, das trocas de idioma e de tema, dos tokens de design e das traduções. Contando, essa moldura tem umas 2.560 linhas, cinco vezes a página. Um micro frontend por rota não precisa de uma página; precisa da moldura inteira em volta dela, idêntica, em dois apps.

Foi aí que estava a decisão de verdade. Copiar a moldura significa duas sidebars divergindo com o tempo. Dar ao app novo uma moldura mínima significa uma página que parece outro site. A resposta que escala é um monorepo com a moldura como pacote compartilhado.

Passo um: um monorepo que não muda nada

Antes de criar qualquer app novo, transformei o repositório num monorepo com pnpm e Turborepo: o site foi para apps/portfolio, e a moldura, o design system e as traduções viraram pacotes. A regra era que quem visita não podia perceber nada.

"Idêntico" tinha que ser medido, não presumido. Antes de mover um único arquivo, salvei o HTML das 122 páginas e o conjunto completo de seletores CSS, e comparei depois de cada passo. Essa comparação se pagou duas vezes:

  • 58 classes CSS sumiram. O Tailwind varria o repositório inteiro, incluindo exemplos de código na documentação, e gerava CSS para eles. Quando passou a varrer só o app, esse CSS morto foi embora. Conferi uma por uma antes de aceitar.
  • A moldura perdeu o estilo quando tirei uma linha. O Tailwind só gera as classes que encontra, e os pacotes ficam fora do app. Sem um @source explícito apontando para eles, a sidebar sai sem estilo e todos os checks continuam verdes.

Depois, uma revisão independente pegou mais um: o Turborepo não sabia que o app dependia dos pacotes, então uma mudança só na moldura reaproveitava o resultado antigo dos testes do app. O CI teria aprovado uma tradução faltando. Uma regra de dependência resolveu.

Passo dois: a zona

Com a moldura como pacote, o app novo é pequeno. Ele serve uma rota, usa a mesma moldura e prefixa os próprios assets para não colidirem com os do portfólio no mesmo domínio:

const nextConfig: NextConfig = {
  // The zone's assets must not collide with the portfolio's on the same domain.
  assetPrefix: "/uses-static",
  transpilePackages: ["@repo/i18n", "@repo/ui", "@repo/shell"],
  async headers() {
    return [{ source: "/(.*)", headers: [{ key: "x-zone", value: "uses" }] }]
  },
}

O portfólio encaminha a rota para ele. Se a URL da zona faltar num build de produção, o build falha em vez de publicar um rewrite apontando para lugar nenhum:

export function usesRewrites(env: { USES_ZONE_URL?: string; NODE_ENV?: string }) {
  const raw = env.USES_ZONE_URL ?? (env.NODE_ENV === "production" ? undefined : "http://localhost:3001")
  if (!raw) throw new Error("USES_ZONE_URL is required in production builds")
  const zone = raw.replace(/\/$/, "")
  return [
    { source: `/${USES_LOCALE_PARAM}/uses`, destination: `${zone}/:locale/uses` },
    { source: `/${USES_LOCALE_PARAM}/uses/:path*`, destination: `${zone}/:locale/uses/:path*` },
    { source: "/uses", destination: `${zone}/uses` },
    { source: "/uses-static/:path*", destination: `${zone}/uses-static/:path*` },
  ]
}

O domínio próprio da zona serve um robots.txt que bloqueia tudo. Os buscadores chegam à página pelo domínio principal, e a URL canônica já aponta para lá.

Um link que sabe para onde vai

A parte sutil foi a navegação. Navegação client-side só funciona dentro do mesmo app: da página Ferramentas, ir para a home com uma rota client-side pediria a rota a um app que não a tem. Então a moldura aprendeu a que app pertence cada caminho:

export type Zone = "portfolio" | "uses"

const LOCALE_GROUP = `(?:${LOCALES.join("|")})`
const USES = new RegExp(`^(?:/${LOCALE_GROUP})?/uses(?:/.*)?$`)

export function zoneFor(pathname: string): Zone {
  return USES.test(pathname) ? "uses" : "portfolio"
}

export function linkMode(current: Zone, href: string): "client" | "document" | "external" {
  if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) return "external"
  const pathname = href.split(/[?#]/)[0] || "/"
  return zoneFor(pathname) === current ? "client" : "document"
}

Todo link e toda navegação programática da moldura passam por essa decisão. Trocar o idioma dentro de Ferramentas continua client-side. Ir de Ferramentas para a home, ou para a busca, carrega um documento novo, de propósito.

O que custou

  • O salto a mais. Servida direto, a zona respondeu em 224 ms; passando pelo rewrite do portfólio, em 267 ms.
  • Client-side não é automaticamente mais rápido. Trocar o idioma dentro da zona (client-side) levou 534 ms, enquanto atravessar da zona para a home (documento completo) levou 318 ms. A transição client-side busca os dados da página através do rewrite: uma ida e volta a mais a cada troca.
  • Os deploys são independentes de verdade. Um commit só na zona rebuilda só a zona; um commit só no portfólio rebuilda só o portfólio; uma mudança na moldura rebuilda os dois, porque os dois dependem dela.
  • A falha tem outro formato. Se a zona cair, /uses falharia e o resto do site continuaria funcionando: rewrite não tem fallback. No experimento com Angular, um micro frontend com falha derrubava um bloco e deixava a página de pé.

O que só a produção mostrou

Tudo passou localmente. Duas coisas só apareceram com os dois apps publicados:

  • Os pré-carregamentos falhavam pelo rewrite. O Next.js 16 pré-carrega uma rota segmento por segmento, com um header especial. Chamada direto, a zona respondia essas requisições. Pelo portfólio, a mesma requisição atravessava o rewrite e chegava à zona de um jeito que ela não sabia servir: 404. Os pedidos completos da página passavam normalmente; só o pré-carregamento quebrava. A navegação continuava funcionando, só que sem pré-carregamento. Minha primeira correção, mover o rewrite para a camada da plataforma (vercel.json), não mudou nada. O que funcionou foi desligar o pré-carregamento dos links dentro da zona: a requisição que falhava simplesmente deixou de existir.
  • A resposta de revalidação vinha sem headers. No experimento com Angular, o painel mostrava 0,3 KB como custo do módulo para quem voltava ao post. O 304 que a plataforma manda na revalidação não traz nenhum dos headers de CORS e de timing, então o navegador só reporta o tamanho dos headers. O painel agora trata isso como cache.

O que o produto gerenciado teria feito

A Vercel tem um produto de Microfrontends que faz esse roteamento no edge, a partir de um único arquivo de configuração, com prefetch entre zonas e um proxy local. Fiz com rewrites comuns do Next.js para um experimento não depender de um produto que poderia ter custo. A diferença é quem opera o encanamento, não se o modelo funciona.

Um repositório, dois donos

Uma pergunta justa é se o app novo não deveria morar num repositório próprio. Micro frontend é sobre deploy e dono independentes, não sobre repositório. Aqui a zona tem app próprio, projeto próprio na Vercel e deploy próprio, e um arquivo CODEOWNERS deixa a responsabilidade explícita. Um repositório separado me obrigaria a publicar a moldura como pacote versionado e manter dois apps sincronizados com ela, que é exatamente a divergência que eu queria evitar. O experimento com Angular ganhou repositório próprio porque não compartilhava nada com o site.

Quando não vale a pena

Para uma pessoa e uma página, isto é exagero, e esse era o ponto. O custo não é a página: é a moldura, o monorepo, os rewrites e um modo de falha novo. Começa a valer quando vários times precisam publicar partes do mesmo site no próprio ritmo. Agora eu sei como é o primeiro passo disso.