back to logs

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
desktop
mobile
packages
ui
utils
hooks
locale
config
tsconfig
database
  • apps/web — next.js app router. seo, auth, api routes. vercel
  • apps/desktop — electron forge. chromium renderer + node main. local models run here
  • apps/mobile — vite + tauri 2. wkwebview / android webview + rust plugins
  • packages/ui — every screen and primitive. no fs, no prisma, no electron, no next imports at the top level
  • packages/utils — domain types, the Store / LLM contracts, the routers that pick a backend
  • packages/hooks, packages/locale — shared hooks, i18n catalogs
  • packages/config, packages/tsconfig — one eslint, one tailwind, one postcss, one tsconfig base. every app extends, none of them redefine
  • packages/database — prisma. imported by apps/web only
three shells around one packages/ui, ports in utils, apps/web doubling as the api, postgres on supabase

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

packages
ui
chat
utils
store.ts
llm.ts
hybrid-llm.ts
apps
web
providers
desktop
providers
mobile
providers
// apps/desktop/src/pages/chat-page.tsx
import { Chat } from "ui/src/chat";
import { llm } from "../lib/llm/desktop-llm";
export function ChatPage() {
return (
<Chat
llm={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-store in 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

provider stack with Store, LLM, TTS, changePage and Link injected from each shell

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.overrides for react / react-dom. three apps and a ui package will otherwise resolve three slightly different reacts
  • hoisted linker. node-linker=hoisted + shamefully-hoist in .npmrc. pnpm's isolated tree is right for libraries and hostile to electron forge and native .node addons
  • turborepo graph. build depends on ^generate and ^build, dev is persistent and uncached, IS_ELECTRON / IS_MOBILE live in globalEnv so 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 like ui/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', ...] and experimental.externalDir: true, or the app router refuses files outside apps/web
  • tailwind has to see the ui. the shared config's content globs include ../../packages/ui/src/**. skip it and every class used only in the ui gets purged
  • compile-time flags for bundler questions. webpack DefinePlugin sets IS_ELECTRON, vite define sets IS_MOBILE. web leaves both undefined. so next/dynamic only gets required on web:
// packages/ui — next/dynamic only on web
declare 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/config ships the eslint, tailwind and postcss configs. pnpm lint at 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

one send from the shared Chat splitting into desktop, web and mobile lanes

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 fs in 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