codingcollectibles

Site ou App? Sim. O guia gambiarrístico para sites feitos em Expo Router

O roteamento foi fácil. O celular fake no navegador foi a parte amaldiçoada.

Escrito em 25 de abril de 2026 - 🕒 11 min. de leitura

Uma das side quests mais chatas de publicar um app na Google Play Store é que o Google às vezes decide que você também virou dono de site.

Lá em 2024, quando eu escrevi pela primeira vez sobre o Musclog, o site existia basicamente porque o Google queria que ele existisse. Eu precisava da política de privacidade, das páginas públicas, do kit básico do desenvolvedor de app respeitável, então subi rapidinho um site pequeno em Next.js, consegui aprovar o app, e segui com a minha vida.

Funcionou perfeitamente até o dia em que eu precisei mexer no site de novo.

Screenshots novas? Outro repo. Texto de feature nova? Outro repo. Atualização de página legal? Outro repo. Ajustezinho de tradução? Outro repo.

Nada disso era difícil de verdade, o que quase piorava a situação. Era só chato o suficiente pra continuar me lembrando que eu tinha dividido um produto em duas bases de código por um motivo que eu já não respeitava mais.

Então agora eu não faço mais isso.

O site do Musclog mora dentro do repo do app. Mesmo projeto com Expo Router. Mesmo deploy. musclog.app é o site público, musclog.app/app é o app web de verdade, e Android + iOS continuam saindo da mesma base de código.

Boa prática? Questionável. Conveniente? Demais.

Site público do Musclog renderizado pela mesma base de código com Expo Router do app
Site público do Musclog renderizado pela mesma base de código com Expo Router do app

Ok mas por quê?

Basicamente porque o Expo Router arrancou a minha última desculpa.

O Musclog já usava Expo Router, então o app já estava organizado em rotas baseadas em arquivos dentro da pasta app/. Aí eu percebi que podia simplesmente criar um grupo de rotas chamado (website), mover as páginas públicas pra lá, deixar o app de verdade em app/app/*, e parar de fingir que eu estava lidando com dois produtos diferentes.

O repo separado tinha virado uma daquelas decisões de arquitetura que parecem limpas na teoria e depois ficam te cobrando imposto de manutenção pra sempre:

  • dois PRs pra uma mudança num produto
  • dois deploys
  • dois lugares pras traduções
  • dois lugares pras páginas legais
  • dois lugares pra esquecer alguma coisa

Sem condições.

A divisão

A parte engraçada é que o roteamento em si acabou sendo a parte menos amaldiçoada dessa história:

  • app/app/* é o Musclog de verdade. Treinos, nutrição, coach com IA, tudo.
  • app/(website)/* é o site público. Landing page, política de privacidade, termos, contato, calculadora.

A rota raiz só checa a plataforma e te joga pra onde você pertence:

// app/index.tsx
import { Redirect, useRouter } from 'expo-router';
import { useEffect } from 'react';
import { Platform } from 'react-native';

export default function Index() {
  const router = useRouter();

  useEffect(() => {
    if (Platform.OS === 'web') {
      router.replace('/home');
    }
  }, [router]);

  if (Platform.OS === 'web') {
    return null;
  }

  return <Redirect href="/app" />;
}

É isso. No web, / vira o site. No native, / vira o app.

O site também ganha o próprio layout mais leve:

// app/(website)/_layout.web.tsx
import { Slot } from 'expo-router';

import { WebsiteChrome } from '@/components/website/WebsiteChrome';
import { WebsiteProviders } from '@/components/website/WebsiteProviders';

export default function WebsiteLayout() {
  return (
    <WebsiteProviders>
      <WebsiteChrome>
        <Slot />
      </WebsiteChrome>
    </WebsiteProviders>
  );
}

Isso importa porque o layout do app de verdade já tinha crescido e virado um layout de app mesmo. Boot de banco, migrações, React Query, safe area, gestos, modais, snackbars, câmera, contexto do coach, todas aquelas mentiras clássicas de “é só um projetinho paralelo”. O site não precisa de nada disso só pra explicar o que o app faz.

E se algum usuário nativo cair numa rota exclusiva do site, a correção é maravilhosamente direta:

// app/(website)/home.tsx
import { Redirect } from 'expo-router';

export default function Home() {
  return <Redirect href="/app" />;
}

Pronto. Volta pros macros.

Site público do Musclog renderizado pela mesma base de código com Expo Router do app
Site público do Musclog renderizado pela mesma base de código com Expo Router do app

A palhaçada do celular no desktop

Foi aqui que eu parei de me comportar como uma pessoa normal.

Eu queria que musclog.app/app, no desktop, mostrasse o app funcionando dentro de uma moldura de celular. Um pouco porque fica bonito. Um pouco porque funciona como preview do produto. Mas principalmente porque, depois que a ideia entrou na minha cabeça, qualquer opção menos ridícula começou a parecer errada.

O detalhe chato era que /app precisava parecer um celular, mas /home definitivamente não. Então só o roteamento não bastava. Eu precisava que o shell bruto de HTML soubesse, antes da hidratação, se o navegador deveria renderizar a gambiarra do celular fake ou não.

Essa lógica mora em app/+html.tsx, dentro de um script no <head> do documento:

function landingPanelGate(base: string) {
  try {
    function update() {
      const raw = window.location.pathname;
      const path = (base && raw.startsWith(base) ? raw.slice(base.length) : raw) || '/';

      if (!path.startsWith('/app')) {
        document.documentElement.classList.add('hide-desktop-wrapper');
      } else {
        document.documentElement.classList.remove('hide-desktop-wrapper');
      }
    }

    update();
    window.addEventListener('popstate', update);

    const origPush = history.pushState.bind(history);
    history.pushState = function (...args) {
      origPush(...args);
      update();
    };

    const origReplace = history.replaceState.bind(history);
    history.replaceState = function (...args) {
      origReplace(...args);
      update();
    };
  } catch (_) {}
}

A parte importante é a gambiarra em pushState / replaceState. A primeira renderização foi tranquila. A navegação client-side é que era a parte chata. Sem isso, dava pra sair de /app e continuar com o shell errado ali, como se o Chrome tivesse esquecido em que página estava.

O shell HTML em si é basicamente o painel da landing, o app roteado, e a moldura do celular:

<body className="expo-web-body">
  <div className="expo-web-landing">...</div>
  <script dangerouslySetInnerHTML={{ __html: LANDING_I18N_SCRIPT }} />
  <div className="expo-web-root">
    <div className="expo-web-app-shell">{children}</div>
    <img
      className="expo-web-phone-frame"
      src={withExpoBaseUrl(PHONE_FRAME_SRC)}
      alt=""
      aria-hidden
    />
  </div>
</body>

Aí o CSS comete o crime:

@media (min-width: 1024px) {
  .expo-web-root {
    --frame-h: min(100dvh, max(min(360px, 100dvh), 85dvh));
    aspect-ratio: 1438 / 2976;
    width: min(100vw, calc(var(--frame-h) * 1438 / 2976));
    max-height: var(--frame-h);
    overflow: hidden;
  }

  .expo-web-app-shell {
    position: absolute;
    left: 7.4409%;
    top: 2.9906%;
    right: 6.3282%;
    bottom: 3.125%;
    zoom: 0.85;
  }
}

Sim, eu medi a área transparente da tela dentro do PNG até o app encaixar direito. Não tem geometria elegante nenhuma por trás dessas porcentagens. Era só eu, uma moldura de celular, e muito empurra-pra-lá até o app parar de parecer torto.

Nas rotas que não são /app, o espetáculo inteiro é desligado:

.hide-desktop-wrapper .expo-web-landing,
.hide-desktop-wrapper .expo-web-phone-frame {
  display: none !important;
}

.hide-desktop-wrapper .expo-web-app-shell {
  position: static !important;
  width: 100% !important;
  height: auto !important;
  zoom: 1 !important;
  overflow: visible !important;
}

Sim, usa !important. Isso é CSS de pré-hidratação cujo trabalho inteiro é manter a mentira de pé. Não estamos fazendo arquitetura refinada nessa camada.

App web do Musclog no desktop renderizado dentro de uma moldura de celular ao lado da landing
App web do Musclog no desktop renderizado dentro de uma moldura de celular ao lado da landing

Três pequenos crimes

Direto pra cadeia. Na hora.

Depois que o shell funcionou, os incômodos menores começaram a aparecer como se tivessem marcado horário.

1. O painel da landing precisava de i18n antes da hidratação

O texto do lado do celular mora em HTML cru, o que significa que React e i18n ainda estão dormindo quando a página renderiza pela primeira vez. Então, se eu quisesse que a página em português não desse um flash de inglês antes, eu tinha que remendar isso manualmente a partir do localStorage:

function landingI18nPatcher(translations, storageKey) {
  try {
    let lang = localStorage.getItem(storageKey);
    let s = (lang && translations[lang]) || translations['en-US'];

    document.querySelectorAll('[data-landing-i18n]').forEach(function (el) {
      let k = el.getAttribute('data-landing-i18n');
      if (k && s[k]) {
        el.textContent = s[k];
      }
    });
  } catch (_) {}
}

Não é elegante. Também evita jogar um flashbang de texto em inglês numa página em português, então eu vou manter.

2. Assets em HTML cru não recebem a ajuda de sempre do Expo

O PNG da moldura do celular e o QR code também são referenciados a partir de HTML cru, então aquela conveniência normal de assets do Expo não me ajudava muito ali. Resultado: eu precisei de um helper pra base URL:

function withExpoBaseUrl(path: string): string {
  const base = process.env.EXPO_BASE_URL;
  if (base == null || base === '') {
    return path;
  }

  const basePath = String(base).replace(/^\/+|\/+$/g, '');
  const normalized = path.startsWith('/') ? path : `/${path}`;
  return `/${basePath}${normalized}`;
}

E como esses arquivos vivem fora do pipeline normal de assets do React Native, eu copio tudo pra public/ antes de rodar dev e export. Esquece esse passo uma vez e o site imediatamente te lembra quem manda.

3. O expo export às vezes precisava de apoio emocional

Em alguns ambientes, expo export --platform web terminava com sucesso e depois só… continuava vivo sem motivo nenhum. Pasta dist lá. Arquivos gerados. Processo espiritualmente concluído, tecnicamente ainda pendurado.

Então agora existe um script wrapper:

// scripts/export-web-wrapper.js
if (output.includes('Exported: dist')) {
  console.log('[export-web-wrapper] Detected successful export. Forcing exit in 5s...');
  setTimeout(() => process.exit(0), 5000);
}

Não é glamouroso… mas resolve.

Aí os modais ficaram esquisitos

Modal do Musclog no desktop renderizado corretamente dentro da moldura do celular
Modal do Musclog no desktop renderizado corretamente dentro da moldura do celular

Claro que ficaram.

Quando o app web mora dentro de um celular fake no desktop, o comportamento padrão de Modal no React Native Web começa a ficar ridículo bem rápido. Fazer portal direto pro document.body funciona bem quando o app é dono da página inteira. Funciona bem menos quando o app está visualmente recortado dentro de uma carcaça de celular e o modal decide do nada que agora pertence à janela inteira do navegador.

Então eu adicionei um WebModalShellProvider lá em cima em app/app/_layout.tsx, com um host de overlay dentro do shell do celular:

// context/WebModalShellContext.web.tsx
return (
  <WebModalShellContext.Provider value={{ hostElement }}>
    <View style={{ flex: 1, minHeight: 0, height: '100%', width: '100%', position: 'relative' }}>
      <View style={{ flex: 1, minHeight: 0, height: '100%', width: '100%' }} collapsable={false}>
        {children}
      </View>
      <View
        id="expo-web-modal-shell-host"
        ref={setHostRef}
        collapsable={false}
        style={{
          position: 'absolute',
          left: 0,
          right: 0,
          top: 0,
          bottom: 0,
          zIndex: 1_000_000,
        }}
      />
    </View>
  </WebModalShellContext.Provider>
);

Aí o Modal.web.tsx faz aquela coisa óbvia que só parece óbvia depois, alternando entre um portal dentro desse host e o modal normal do RN:

const useShellPortal = Platform.OS === 'web' && isDesktopFrame && hostElement != null;

if (useShellPortal) {
  return createPortal(
    <View
      style={{
        position: 'absolute',
        left: 0,
        right: 0,
        top: 0,
        bottom: 0,
        width: '100%',
        height: '100%',
      }}
    >
      {children}
    </View>,
    hostElement
  );
}

return <RNModal visible={visible}>{children}</RNModal>;

E o hook compartilhado do overlay sabe se ele deve se comportar como um modal de viewport inteira ou como um modal de viewport de celular fake:

export function useWebModalLayerStyle(options = {}) {
  const isDesktopFrame = useWebDesktopPhoneFrame();

  if (isDesktopFrame) {
    return {
      position: 'absolute',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      width: '100%',
      height: '100%',
    };
  }

  return {
    position: 'fixed',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    width: '100vw',
    height: '100dvh',
  };
}

Isso resolveu a parte visual. Aí os pointer events decidiram que também queriam atenção.

No native, pointerEvents="box-none" é normal. No HTML, isso vira pointer-events: box-none, que não é CSS de verdade, então o navegador fica livre pra improvisar. Então agora existe uma regra defensiva pro host do overlay:

.expo-web-app-shell #expo-web-modal-shell-host {
  pointer-events: none !important;
}

.expo-web-app-shell #expo-web-modal-shell-host * {
  pointer-events: auto !important;
}

Também existe um useLayoutEffect forçando a mesma coisa inline, porque a essa altura eu já não tinha mais interesse em descobrir quão elegante seria a solução correta.

Eu claramente tenho um padrão

Se você já lê este blog há algum tempo, nada disso deveria te surpreender.

Eu já transformei este blog num RPG top-down porque o Konami Code merecia uma recompensa maior do que só um efeitinho de fundo.

Depois eu coloquei Stories no blog porque o app da minha operadora tinha Stories, e isso me irritou profundamente num nível suficiente pra eu transformar o problema em problema de todo mundo.

Então sim, é claro que agora o meu app de fitness serve o site a partir do mesmo repo com Expo Router, e é claro que o app web no desktop roda dentro de uma foto gigante de um celular. Isso não é uma escalada surpreendente. É só continuidade.

Por que eu vou manter isso

Passando da piada, isso resolveu um imposto de manutenção bem real.

As traduções são compartilhadas. Os design tokens são compartilhados. Os componentes são compartilhados. As páginas legais ficam do lado do produto que elas estão legalmente defendendo. Quando eu mudo texto, screenshot ou posicionamento de feature, eu não preciso lembrar qual repo guarda a versão pública e comportada do mesmo app.

O deploy também ficou melhor. Um build. Um export. Um artefato. O site e o app web não têm como sair de sincronia a não ser que eu faça questão de separá-los, o que fica bem mais difícil quando eles literalmente são publicados juntos.

E quando eu lancei o redesign do Musclog, o site basicamente veio junto no embalo. Mesmo spacing, mesmas cores, screenshots atualizadas, mesmo PR. Exatamente o tipo de vitória chata de manutenção que faz uma decisão de arquitetura meio estranha parecer inteligente seis meses depois.

Conclusão

Eu poderia ter mantido o site num repo separado em Next.js como uma pessoa emocionalmente mais estável? Poderia.

Eu ia continuar fazendo isso depois que o Expo Router deixou esse caminho aberto? Não.

A parte engraçada é que o roteamento foi a parte sem graça. O Expo Router resolveu isso muito bem. O trabalho irritante estava em todas as mentirinhas de navegador ao redor: bloqueio de rota antes da hidratação, i18n em HTML cru, sincronização de assets, modais que sabiam em que shell estavam, esquisitice com pointer-events, e convencer o Chrome do desktop a acreditar temporariamente que era um celular.

Ainda assim, valeu muito a pena.

Se quiser fuçar o código, o Musclog é open-source no GitHub. Se quiser usar, tá em musclog.app. O site mora dentro do repo do app agora.

Agora mora todo mundo aqui.

Tags:


Publicar um comentário

Comentários

Nenhum comentário.