ONE CODEBASE, THREE SHELLS, SIX PLATFORMS
code is very accessible now. anyone can get a working screen out of a prompt. so the thing that still costs something is distribution — being on the platforms people already have open. more platforms, more reach. the question is how to do that without maintaining the same app three times
the other side of accessible code is slop. when generating is cheap, verifying is the job. a repo where every package has one purpose and the ui exists exactly once is a lot easier to read line by line than three drifting copies. for a small team shipping a d2c app, that structure isn't a nice-to-have. it's the only way to keep up
I learned this on a side project I was helping someone much more senior with. it was the first time I saw a monorepo that was actually structured, not just "a few folders in one git repo". the maintainability stuck with me. this post is that setup, written down so it can be replicated
0.5 the shape
one react ui, three hosts. web tech all the way down
- web — next.js. the product, plus the api the other two talk to
- desktop — electron. mac, windows, linux from one build
- mobile — tauri 2. ios and android around a webview
the ui was already responsive for mobile web. wrapping it in a webview shell is just importing the same package from a different scaffold. pnpm workspace + turborepo hold it together
apps/web— next.js app router. seo, auth, api routes. vercelapps/desktop— electron forge. chromium renderer + node main. local models run hereapps/mobile— vite + tauri 2. wkwebview / android webview + rust pluginspackages/ui— every screen and primitive. no fs, no prisma, no electron, no next imports at the top levelpackages/utils— domain types, theStore/LLMcontracts, the routers that pick a backendpackages/hooks,packages/locale— shared hooks, i18n catalogspackages/config,packages/tsconfig— one eslint, one tailwind, one postcss, one tsconfig base. every app extends, none of them redefinepackages/database— prisma. imported byapps/webonly
1.0 web is the backend
any web framework would do here. we used next.js because the app is dynamic and the app router gives ssr, localized urls and metadata for free. the useful part is that its api routes double as the backend for desktop and mobile. auth, cloud chat, payments, cron — all apps/web/src/app/api
storage is serverless: postgres on supabase, prisma in packages/database, deployed on vercel. nothing about the shape needs that — a dedicated backend slots in the same place if bandwidth allows. the point is that desktop and mobile are clients of the web app. secrets and the database never live on a device
2.0 ports, not ifdefs
this is the part that makes one packages/ui survive three bundlers
the ui never touches the disk, never spawns a model, never calls storekit. it takes a Store, an LLM, a TTS, a LinkComponent, a changePage. packages/utils owns those interfaces. each shell implements them and injects at the root
// apps/desktop/src/pages/chat-page.tsximport { Chat } from "ui/src/chat";import { llm } from "../lib/llm/desktop-llm";export function ChatPage() {return (<Chatllm={llm}store={window.electron.store}models={DESKTOP_MODELS}LinkComponent={LinkComponent}/>);}
web passes a dexie-backed store and the next Link. mobile passes a sqlite store and the react-router Link. the chat component is one file
three store backends behind one interface:
- web — dexie +
localStorage - desktop —
electron-storein main only. renderer is sandboxed; a codegen script reads the interface and writes the preload + ipc handlers so both sides stay in sync - mobile — sqlite via
tauri-plugin-sql, talking to rust
LLM follows the same pattern. HybridLLM wraps a platform-local implementation and routes: cloud key → api, local key → ollama on desktop, stub on web and mobile. the ui doesn't know which branch ran
3.0 routing and data
routing is the one thing we didn't unify. web needs the app router. electron and tauri load a static index.html, so both use react-router's HashRouter — no history fallback needed on file://. the route table is duplicated (thin), the pages are shared (thick). fine trade
data fetching in packages/ui is swr. cache by key, revalidate on focus, same hook on every platform because fetch is the only thing it depends on — and each shell decides what fetch means. on mobile it's swapped for the tauri http plugin at boot because the webview is cors-strict. the ui keeps calling fetch
4.0 the config that holds it
none of this works without the workspace agreeing on react, typescript and css. this is the part that gets skipped
- pin react once. root
pnpm.overridesforreact/react-dom. three apps and a ui package will otherwise resolve three slightly different reacts - hoisted linker.
node-linker=hoisted+shamefully-hoistin.npmrc. pnpm's isolated tree is right for libraries and hostile to electron forge and native.nodeaddons - turborepo graph.
builddepends on^generateand^build,devis persistent and uncached,IS_ELECTRON/IS_MOBILElive inglobalEnvso the cache key knows which shell it built
{"pipeline": {"build": { "dependsOn": ["^generate", "^build"] },"dev": { "dependsOn": ["^generate"], "cache": false, "persistent": true },"generate": { "dependsOn": ["^generate"], "cache": false },"make": { "dependsOn": ["^generate"] },"lint": {}}}
- consume source.
"ui": "workspace:*","types": "./src", deep imports likeui/src/chat. no barrel file, no published tarball. publishing the ui would rebuild the three-codebases problem with extra steps - next has to be told.
transpilePackages: ['ui', 'utils', ...]andexperimental.externalDir: true, or the app router refuses files outsideapps/web - tailwind has to see the ui. the shared config's
contentglobs include../../packages/ui/src/**. skip it and every class used only in the ui gets purged - compile-time flags for bundler questions. webpack
DefinePluginsetsIS_ELECTRON, vitedefinesetsIS_MOBILE. web leaves both undefined. sonext/dynamiconly gets required on web:
// packages/ui — next/dynamic only on webdeclare const IS_ELECTRON: boolean;declare const IS_MOBILE: boolean;export function dynamicComponent(Component: React.ComponentType) {if (IS_ELECTRON || IS_MOBILE) {return (props) => <Component {...props} />;}const dynamic = require("next/dynamic").default;return dynamic(() => Promise.resolve(Component), { ssr: false });}
runtime store.appPlatform() still exists, but it answers product questions ("show the model download ui?"), not bundler ones
- one eslint.
packages/configships the eslint, tailwind and postcss configs.pnpm lintat the root runs every app and package. one rule set, one command, nothing drifts
5.0 same button, three paths
"send a message":
desktop, local model — Chat calls llm.generateChat. HybridLLM sees a local key and hands off to DesktopLLM. ipc through preload into main, main talks to the ollama child process, tokens stream back over webContents.send. store.upsertConversation writes json on disk over the same bridge
web, cloud model — same Chat. HybridLLM routes to cloudLLM. http hits apps/web api routes, postgres on supabase. WebStore.upsertConversation writes dexie
mobile — same Chat. cloud path, fetch is the tauri http plugin, MobileStore.upsertConversation is INSERT OR REPLACE in sqlite
the ui file doesn't change
6.0 what it cost
three bundlers. webpack for electron, turbopack for next, vite for tauri. the same typescript path issue gets fixed three times
the Store interface is big. codegen makes it survivable. smaller ports would be cleaner on a fourth platform. there was no fourth platform
hoisting bites at package time. dev works, the signed dmg is missing a native addon. forge prunes from apps/desktop/node_modules and the package lives at the root. a packageAfterPrune hook copies it back
a webview is still a webview. safe-area, keyboard height, in-app purchases — all plugins. no native list recycling. for a chat ui that was the right bet
7.0 what I took from it
- every package has one job. the folder name is the api
- the ui exists once. a fix lands on three platforms in one commit
- things break at compile time — a stray
import fsin the ui fails the desktop build instead of crashing a user - lint, test, build are one command each from the root
- when more of the code is generated, this is what makes review possible. there's one place to read
one react library, three hosts, ports for anything that isn't the dom
the rest is config. the config is the part that makes it hold