Munchable is an Expo Router app. It is on Google Play, and it is also at app.munchable.app, running in a browser, from the same files. Not a rewrite, not a wrapper, not a second frontend: the marketing site is a separate Next.js app, but the product itself is one codebase with two runtimes.
Here is the entire HTML document the browser version serves, trimmed only of the comments Expo puts in it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>Munchable</title>
<style id="expo-reset"> ... </style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/_expo/static/js/web/entry-b0cd8e68.js" defer></script>
</body>
</html>
1,176 bytes, one title, no description, one script. That is the whole server-side contribution. The bundle behind it is 3,820,858 bytes raw and 942,080 over the wire with Brotli, served public, max-age=31536000, immutable because its name contains its own hash.
The config that produces this is three lines in app.json:
"web": { "bundler": "metro", "output": "single" }
single means a single-page app: one HTML entry, client-side routing, no prerendering of any route. For a product that is a signed-in tool with a camera in it, that is the right trade. There is nothing on these screens a crawler wants, the pages a crawler wants are the public guides on the marketing site, and prerendering would mean every screen has to survive running once without a browser.
The cost of "single": your HTML shell is not yours
Expo Router supports a +html.tsx file, the document shell for your routes. It is honoured for static and server output. It is not honoured for output: "single", which serves the default shell above.
That sounds academic until you notice the colour. This app is cream, #FFF8EC, everywhere. The browser's default page background is white. Any part of the viewport the app does not paint shows white: behind a screen shorter than the window, and during overscroll at the top and bottom of a page. On native there is no such gap, because there is no document.
So the shell gets patched at load, in the root layout, outside the component:
if (Platform.OS === 'web' && typeof document !== 'undefined') {
document.documentElement.style.backgroundColor = '#FFF8EC';
document.body.style.backgroundColor = '#FFF8EC';
}
Two guards for one line of effect, because this module is also loaded on a phone, and during a test run where there is no document at all.
That is the shape of nearly every web-specific line in this app. Across 104 TypeScript and TSX files in the app, there are 14 Platform.OS === 'web' branches spread over 8 files, against exactly one === 'ios' and one === 'android'. The platform that needs the special cases is not the one with the camera.
The one file that is web only
Metro resolves ./Stack to Stack.web.tsx in a browser and Stack.tsx on a phone. That is the only .web.*, .native.*, .ios.* or .android.* file in the repository, and it is 196 lines.
It exists because of a specific absence. React Navigation's native stack, on the web, is a plain pile of absolutely positioned views, one per route, with no transitions of its own. The plain layout is good: it is exactly what the app's scroll views want. The missing motion is the problem, because a product whose screens all look alike needs the direction of travel to mean something. Expo Router's vendored JavaScript stack was the obvious alternative and got rejected: it sizes card content for body scrolling and toggles pointer events from animation listeners, which left pages that would not scroll to the bottom, or would not scroll at all until a reload.
So the native stack is kept and the motion is added on top: every screen is wrapped in a view that slides in from the right when it is pushed, and holds its own removal until it has slid back out. The screens that are tasks rather than places (the camera, the paywall, the menu reader) name slide_from_bottom and travel vertically instead.
Three details in there took longer than the animation did.
Every route is a transparent modal. The native stack hides every route except the focused one, unless the route above it is transparent. A slide that reveals nothing is not a slide, so on the web every screen is presented as transparentModal. Each page paints its own background, so nothing actually shows through.
The content style is overflow: hidden. A page translated a viewport to the right is still in the document. Without the clip, the browser grows a scrollable area a full viewport wide and tall to reach it, and the app ends up sitting in the top-left quarter of a page you can scroll away from. This is the single most confusing bug I have had in the web build, and it looks nothing like its cause.
Chrome will not let you hide a focused element from assistive technology. The stack marks every unfocused route aria-hidden. At the moment a new page is pushed, the browser's focused element is still the button that pushed it, inside the page that is now being hidden. Chrome refuses the attribute and logs a warning rather than hide a focused element from the accessibility tree, which is the correct call: a screen reader user would be focused on something that is no longer announced. So a page that loses focus blurs first, in a layout effect, in the same task as the DOM update:
useLayoutEffect(() => {
if (focused || typeof document === 'undefined') return;
const node = host.current as unknown;
const active = document.activeElement;
if (node instanceof HTMLElement && active instanceof HTMLElement && node.contains(active)) {
active.blur();
}
}, [focused]);
A useEffect is too late: the accessibility check has already run and complained.
And the whole thing reads useReducedMotion() first. With Reduce Motion on, the push offset starts at zero and the leaving animation is skipped entirely, so pages swap with no travel at all. That is worth saying out loud because the honest version of this story is that the marketing site does not do as well: its CSS animations respect the preference and its JavaScript ones did not. The app build got it right because Reanimated puts the hook directly in front of you.
What it is worth
The practical payoff is not reach, it is iteration. A camera screen still needs a phone. But every other screen in this product, the conditions picker, the recipe list, the history, the settings, can be opened in a desktop browser with devtools attached, which is a different quality of debugging than reading a Metro log. It is also how you look at your own app the way a first-time user does, with no build step between the thought and the look.
If you want to compare the two surfaces: app.munchable.app is the product, and munchable.app is the Next.js marketing site in front of it, with the public condition guides and recipes that the app does not need to serve. Two apps, one of which runs in two places.
Three things I would tell anyone pointing an Expo app at a browser:
- Decide
outputbefore you write a screen.singlecosts you the HTML shell, andstaticcosts you every screen that assumes a browser exists at module scope. - Expect your platform branches to be mostly about the document, not about the device.
- The moment you add your own transitions on the web, you own the accessibility tree too. Hiding a route is a claim about what is announced, and the browser is going to argue with you.
Top comments (0)