ONE REACT CODEBASE, THREE SHELLS
I spent a stretch of my career on a product that ships the same react ui to the browser, a desktop app, and ios/android — without react native, without flutter, and without three product teams
the concrete product was hammerai. the pattern is what this post is about
stack is web tech all the way down:
- web — next.js
- desktop — electron (chromium + node)
- mobile — tauri 2 (wkwebview / android webview + rust)
shared screens live in one package. each platform is a thin shell that injects storage, inference, auth, and navigation. monorepo is pnpm + turborepo
0.5 the wrong ways
"we want one codebase" usually turns into one of:
- three repos. a next.js site, an electron app, a react native app. the chat screen is rewritten three times. a button padding change takes a week
- react native everywhere. you get native widgets and a metro bundler. you lose the next.js seo surface, you fight web-only libraries, and "share with the marketing site" becomes a lie
- a webview wrapper with no architecture. cap the website in electron/tauri, pray cors and
localStoragebehave, then discover that local llms, file systems, and in-app purchases do not live in the dom
the third option is closer to right than people admit. chromium and mobile webviews can render a serious react app. the failure mode is letting the ui import fs, next/dynamic, and window.electron directly
the version that works treats native apps as shells and the react tree as a library with ports:
shared ui never talks to the disk, never talks to ollama, never talks to storekit. it talks to a
Store, anLLM, aTTS, aLink, and achangePagefunction. each shell implements those
that is hexagonal architecture with a react costume. it is also the only reason one packages/ui can survive three bundlers
1.0 the monorepo at a glance
apps/web— next.js. seo, auth, api routes, prisma. also the backend desktop/mobile call in local devapps/desktop— electron forge. chromium renderer + node main. local ollama lives hereapps/mobile— vite + tauri 2. wkwebview / android webview + rust pluginspackages/ui— react screens and primitives. no fs. no prisma. no electronpackages/utils— domain types +HybridLLM+HybridTTS+ theStore/LLMcontractspackages/database— prisma. web server only. not a client package
diagram to drop in:
public/logs/one-codebase-hld.webp— three shells around onepackages/ui, arrows intoutilsports, label the bundlers (next / webpack / vite)
2.0 thin shells, thick screens
routing is the one place we did not unify. web needs the app router for ssr, localized urls, and metadata. electron and tauri load a static index.html — HashRouter does not need a history api fallback on file://. so you duplicate the route table (thin) and share the screens (thick). that is the right split
each app's pages are wrappers that wire ports into the same chat component:
// 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 and mobile do the same with their store, their image generator, their router Link. the 2,000-line chat component exists once
3.0 the injection seam
this is the architecture. everything else is packaging
packages/ui renders a chat. it accepts an llm: LLM and a store: Store. it does not construct them. packages/utils owns the contracts. each shell's providers inject the platform implementations at the root — same provider tree shape, different backends
compile-time flags answer bundler questions. runtime store.appPlatform() answers product questions ("show the ollama download ui?"). do not mix them
// 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 });}
webpack/DefinePlugin sets IS_ELECTRON. vite define sets IS_MOBILE. web leaves both undefined so the next path runs. if the ui require('next/dynamic') inside an electron bundle, you drag next into a desktop webpack build or you crash
three store backends, one typescript interface:
- web — dexie +
localStoragein the browser - desktop —
electron-storein main only (sandboxed renderer + codegen'd ipc/preload) - mobile — sqlite via
tauri-plugin-sql, talking to rust
diagram to drop in:
public/logs/one-codebase-ports.webp— provider stack (GeneralStore→Auth→SharedContext) with injected ports feeding in from the side. make it obvious the chat screen never constructs those
4.0 same button, three paths
end-to-end, "send a message":
desktop, local model
Chatinpackages/uicallsllm.generateChat(...)HybridLLMsees a non-cloud key, delegates toDesktopLLM- ipc through preload into main → ollama child process
- tokens
webContents.sendback;store.upsertConversationpersists over the same bridge to json on disk
web, cloud model
- same
Chatcomponent HybridLLMroutes tocloudLLM- http hits
apps/webapi routes → postgres WebStore.upsertConversationwrites dexie
mobile
- same
Chatcomponent - cloud/proxy path (local llm is a stub — phones are not the local-70b machine)
fetchis the tauri http plugin (webview cors is strict)MobileStore.upsertConversationisINSERT OR REPLACEin sqlite
the ui file does not change
diagram to drop in:
public/logs/one-codebase-send.webp— one "send" from sharedChatsplitting into three lanes. caption: same component, three hosts
5.0 footguns worth one line each
- tailwind content globs must include
packages/ui, or every class used only there gets purged and the shared chat ships unstyled - next
transpilePackages+externalDiror the app router refuses files outsideapps/web - pnpm hoisted linker keeps electron forge and native
.nodeaddons alive at install time; forge's prune still deletes hoisted packages from the asar — budget apackageAfterPrunecopy-back or watch your tts.dylibvanish from the signed dmg - pin react once at the root with pnpm overrides. three apps + a ui package will otherwise resolve three slightly different reacts
6.0 tradeoffs
three bundlers is real cost. webpack (electron), turbopack (next), vite (tauri). you will fix the same typescript path issue three times. we paid the tax for next's seo and electron's ecosystem
the Store interface is too large. codegen makes it survivable; it does not make it good. focused ports (SettingsStore, ConversationRepo, …) would be easier on a fourth platform. we did not have a fourth platform. we had a working chat
hashrouter vs app router duplicates routes. screens are shared; urls are not. live with it, or build a route table generator — we lived with it
a tauri mobile app is still a website in a webview. safe-area, keyboard height, iap, age signals: all plugins. you are not getting swiftui list recycling. you are getting one chat component. for this product that was the correct bet
web is the backend. desktop and mobile are clients of apps/web. prisma and secrets stay off the device. you also cannot pretend the desktop app is fully offline if any feature hits cloud auth
7.0 what to copy
- pnpm workspace + hoisted linker + react overrides. get install boring before you write a screen
- turborepo with
^generate/^build, persistentdev, and platform flags inglobalEnv - a
packages/uithat only imports react and your port types. deep imports. no barrel file. tailwind content globs that include that package - ports, not ifdefs.
Store,LLM,TTS,LinkComponent,changePage,useUser. inject them at the app root - compile-time
IS_ELECTRON/IS_MOBILEfor bundler concerns. runtimeappPlatform()for product concerns - thin shells. next owns seo and the api. electron owns node, child processes, and a sandboxed renderer. tauri owns rust plugins and the mobile os. none of them own the chat transcript ui
the reusable sentence:
one react library, three hosts, ports for anything that is not the dom
that is the system. the rest is config, and the config is the part people skip — until next/dynamic ships inside an .app bundle, or tailwind purges every class in packages/ui, or forge prunes the native addon you tested all week