DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Every URL on our app subdomain returns the same 1,176 bytes, and the file that should paint it the right colour never runs

Munchable's phone app is also served as a website at app.munchable.app. Not a port, not a rebuild: the same Expo Router tree, exported for the web and hosted as static files. You can see the whole shape of that deployment with curl, and the surprising parts are all in what is missing.

$ curl -sD- -o body.html https://app.munchable.app/totally-made-up-path-xyz
HTTP/2 200
cache-control: public, max-age=0, must-revalidate
content-type: text/html; charset=utf-8
x-robots-tag: noindex, nofollow

$ wc -c body.html
1176 body.html
Enter fullscreen mode Exit fullscreen mode

A path that does not exist returns 200 and 1,176 bytes. So does /, and so does /recipes. There is exactly one HTML file on the whole origin.

One line of app config decides all of it

"web": {
  "bundler": "metro",
  "output": "single"
}
Enter fullscreen mode Exit fullscreen mode

Expo Router offers static for the mode where every route is rendered to its own HTML file, which is what you want for a content site, and single for a single-page app. Our content site is a separate Next.js app at munchable.app. This one is the product, behind a sign-in, so it is single, and everything below follows from that.

The rewrite is not optional

{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
Enter fullscreen mode Exit fullscreen mode

Without it, a static host does the only sensible thing with /recipes: it looks for recipes/index.html, does not find it, and serves a 404. Client-side routing works perfectly right up until somebody refreshes the page or follows a link from outside, which is to say, right up until a real user arrives.

With it, the server stops having opinions about paths. Every request gets the shell, the bundle boots, and Expo Router reads location.pathname and decides which screen that is. The router owns the URL space completely, which is also why a made-up path returns 200: there is nothing on the server that knows the difference between a real route and a typo. The app renders its own not-found screen after the JavaScript has run.

The shell is the stock one, and that is the interesting part

Here is most of the 1,176 bytes:

<!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">
      html, body { height: 100%; }
      body { overflow: hidden; }
      #root { display: flex; height: 100%; flex: 1; }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script src="/_expo/static/js/web/entry-ef7c3f82cc71fd0a5bd035bfc4f9f67c.js" defer></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

No brand colour, no fonts, no meta description. The documented way to change that is app/+html.tsx, and the docs are precise about what it is for:

This file exports a React component that only ever runs in Node.js

It runs during static rendering. With output: "single" there is no static rendering, so there is no Node.js pass, so the file has nothing to run in. You can add one and watch it be ignored.

Which leaves the document with no background colour. On a screen whose content is shorter than the viewport, or during an overscroll bounce, what shows through is white, inside an app that is cream everywhere else. The fix is four lines at the top of the root layout, outside the component:

// On the web (app.munchable.app is the Expo app served as an SPA, web.output
// "single"), paint the page cream so no bare white shows through. The
// `+html.tsx` shell that would normally do this is only honoured for static/
// server output, not "single", so we set it on the document at load instead.
if (Platform.OS === 'web' && typeof document !== 'undefined') {
  document.documentElement.style.backgroundColor = '#FFF8EC';
  document.body.style.backgroundColor = '#FFF8EC';
}
Enter fullscreen mode Exit fullscreen mode

Module scope, not an effect. It runs the moment the bundle is evaluated, which is before React mounts anything, so there is no frame where the background is unset. The Platform.OS test keeps it off native and the typeof document test keeps it out of any non-browser evaluation of the module.

I am not thrilled about writing to document.body.style from an app module. It is still the right call: the alternative is switching the entire output mode, and prerendering an app whose every screen requires a session buys nothing and costs the deep-link behaviour above.

Two cache rules that have to disagree

{ "source": "/_expo/static/(.*)",
  "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] },
{ "source": "/((?!_expo/|assets/).*)",
  "headers": [{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }] }
Enter fullscreen mode Exit fullscreen mode

The filename in that script tag is entry-ef7c3f82cc71fd0a5bd035bfc4f9f67c.js. The hash is content derived, so that URL can never mean anything else, and a year of immutable is the correct answer for it. Live:

$ curl -sI https://app.munchable.app/_expo/static/js/web/entry-ef7c3f82cc71fd0a5bd035bfc4f9f67c.js
cache-control: public, max-age=31536000, immutable
Enter fullscreen mode Exit fullscreen mode

The shell is the opposite case. It is the one file whose URL stays the same while its contents change, because its entire job is to name the current bundle. Cache it for even a few minutes and a deploy leaves people booting yesterday's app from a URL that still looks right. So it is max-age=0, must-revalidate, which still allows a conditional request and a 304, and still pays one round trip per navigation rather than shipping the bytes again.

The negative lookahead in the second source is what keeps the two rules from overlapping. /((?!_expo/|assets/).*) means "everything not already covered above", and assets/ is in there alongside _expo/ because Metro writes hashed font and image files there too. Leave it out and both rules match the same response, and which Cache-Control survives becomes a question about header precedence rather than a decision you made. Writing the exclusion by hand costs one regex and removes the question. It is worth checking with curl rather than reasoning about, which is why the commands above are in this post.

Everything is noindex, including the assets

{ "source": "/(.*)", "headers": [{ "key": "X-Robots-Tag", "value": "noindex, nofollow" }] }
Enter fullscreen mode Exit fullscreen mode

Every path on this origin serves the same HTML, so anything a crawler indexed here would be one page duplicated across infinite URLs, competing with the marketing site for the brand's own queries. The header is on /(.*), which is why even the JavaScript bundle carries it.

There is a robots.txt with Disallow: / as well, and the relationship between those two is less obvious than it looks: a crawler that obeys the disallow never fetches the URL and therefore never reads the header. I wrote that one up separately, because getting it wrong is how a page stays in an index forever.

See it working

  • app.munchable.app is the deployment. Add any nonsense path to it and you will get the app, not a 404, which is the rewrite doing its job. Then look at the page background before the bundle finishes: cream, not white.
  • munchable.app is the Next.js content site on the same brand, with a real HTML page per URL, which is the comparison that explains why these are two deployments and not one.
  • munchable.app/recipes and the recipes screen inside the app render from the same source, which is the whole reason the Expo tree has to work on the web at all.

The short version: output: "single" is a perfectly good answer for a product behind a login, but it hands you the server's routing table, the cache policy and the document shell, and the third one has no config file.

Top comments (0)