Munchable is a barcode scanner for people with a gut condition. You point a phone at a packet and it tells you whether that packet fits the conditions you have told it about, with the reasons written underneath. It also has a website, and the website answers the same questions as static pages: 461 of them, one per question, like Is onion low FODMAP? and Does chocolate cause reflux?. The index for the lot is at munchable.app/answers.
Two surfaces, one question, and the answer has to be the same in both. That constraint decided more of this codebase than any other single thing, so it is worth writing down what it cost.
The obvious design is an endpoint, and it loses twice
The normal way to share logic between a mobile app and a website is to put it behind an HTTP route and have both call it. It is the default for good reasons: one deployment, one version, no duplicated logic to drift.
It loses twice here.
The first loss is the shop. The app is used standing in an aisle, on a phone with one bar of signal, and an answer that waits on a round trip is an answer that arrives after you have already put the thing in the basket.
The second loss is the thing the product is actually promising. To get an answer out of a server you have to tell the server which conditions you have. That turns a list of medical conditions into a request body, on a route, with a user id attached. Our privacy page says that linkage does not exist on our side, and the only way to keep saying that is to never need it. The answer is computed where the question is asked.
So the logic is a workspace package instead, and both surfaces compile it in.
The package exports TypeScript, not a build
{
"name": "@munchable/rules-engine",
"type": "module",
"exports": { ".": "./src/index.ts" },
"main": "./src/index.ts",
"types": "./src/index.ts",
"sideEffects": false
}
There is no dist, no tsc -b, no prepublish, and no watcher. The entry point in every field is a .ts file, and each consumer compiles it with the compiler it already runs:
- the Expo app through Metro, which needs
watchFolderspointed at the workspace root and bothnode_modulesdirectories innodeModulesPathsso a pnpm layout resolves, - the Next.js app through
transpilePackages: ['@munchable/rules-engine', '@munchable/pricing'], - and the package's own tests through
node --test 'src/**/*.test.ts', with no test framework and no transform step at all. 204 tests, 374 milliseconds, which is the kind of number that only survives if nothing in the package needs building.
The reason to like this is not the saved build step. It is that there is no moment where a stale artefact can exist. A shared package with a dist has a state where the source is new and one consumer is holding the old compile, and that state is reached by forgetting one command. Shipping source has no such state.
The price is paid by the consumers: anything that wants this package has to be able to compile TypeScript. There is no node script.js that can import it. The split shows up in the test scripts across the workspace, and it is a tidy summary of which packages kept themselves portable:
// the dependency-free packages
"test": "node --test 'src/**/*.test.ts'"
// the two apps
"test": "node --import tsx --test 'lib/**/*.test.ts'"
The packages that import nothing run on bare Node and its type stripping. The apps, which import React and a database client and the rest of it, need a loader in front.
Zero dependencies is a rule, not a boast
"devDependencies": { "@types/node": "^22.10.0", "typescript": "^5.7.2" }
That is the whole dependency list. Nothing at runtime.
This is the part I would push hardest on if you are about to build the same thing. A package that runs inside Metro, inside a Next build, and under node --test is a package that has to survive three module resolvers, three sets of polyfills and three ideas about what the platform provides. Every dependency you add is a chance that one of those three disagrees, and you find out in the bundler that is hardest to debug.
Three house rules keep it portable:
-
No
node:imports. Nothing reads a file, nothing touches a path, nothing asks for an environment variable. Data that would be JSON on a server is a TypeScript module here, 480 KB of them, becauseimport data from './data/x.ts'works in all three places andreadFileSyncworks in one. - The entry point is a pure function. Product in, answer out, no clock, no randomness, no network, no model. Which also means its tests are tables rather than fixtures with mocks.
-
sideEffects: false, and it has to be true, or the parts of the package a surface does not use cannot be dropped from a mobile bundle.
Those three are not clean-architecture points. They are the specific lines that stopped me having to debug a bundler.
The third consumer is the website's build
The two surfaces I started with are the app and the site, but the site counts twice, because the pages do not just link to an answer: their sentences are produced by calling the package while the site is being built.
Every one of those 461 pages exists because the package has something to say about an ingredient and a condition. The page's heading, its answer line and the reason underneath it come from the same call the phone makes, run once at build time and baked into the HTML. The route is marked so a build fails rather than silently falling back to rendering per request:
export const dynamic = 'error';
export const dynamicParams = false;
So the pages cannot drift from the app, because nobody wrote them. That has one consequence worth stating plainly: I cannot edit a sentence on those pages. If a line reads badly, the fix is in the package or in the phrasing table, and it changes every page that shares the shape. I have wanted to make an exception twice and I have not, because the first exception is the end of the guarantee.
What it costs
A package compiled into two shipping surfaces means a change lands in two places on two schedules. The website takes it at the next deploy, within minutes. The app takes it at the next release, which is a store review away. For a moment that is measured in days rather than seconds, the site and the app can be on different versions of the same answer, and the only mitigation is that both are derived from one source so the difference is a version lag and never a divergence.
If you are weighing the same trade: put the logic in a package when the answer has to exist offline or has to be computed without telling your server something about the person asking. Put it behind a route when it depends on data you hold and cannot ship. The deciding question is not reuse, it is who has to know the inputs.
You can see the output of the build-time half on any of the question pages, starting from the index, and the longer reasoning for each condition is in the condition guides.
Top comments (0)