back to logs

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:

  1. 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
  2. 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
  3. a webview wrapper with no architecture. cap the website in electron/tauri, pray cors and localStorage behave, 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, an LLM, a TTS, a Link, and a changePage function. 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
desktop
mobile
packages
ui
utils
hooks
locale
config
tsconfig
database
  • apps/web — next.js. seo, auth, api routes, prisma. also the backend desktop/mobile call in local dev
  • apps/desktop — electron forge. chromium renderer + node main. local ollama lives here
  • apps/mobile — vite + tauri 2. wkwebview / android webview + rust plugins
  • packages/ui — react screens and primitives. no fs. no prisma. no electron
  • packages/utils — domain types + HybridLLM + HybridTTS + the Store / LLM contracts
  • packages/database — prisma. web server only. not a client package
high-level diagram: three shells (next.js, electron, tauri) around one packages/ui, arrows into utils ports, bundlers labeled

diagram to drop in: public/logs/one-codebase-hld.webp — three shells around one packages/ui, arrows into utils ports, 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.htmlHashRouter 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.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 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
chat
utils
store.ts
llm.ts
hybrid-llm.ts
apps
web
providers
desktop
providers
mobile
providers

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 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 });
}

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 + localStorage in the browser
  • desktopelectron-store in main only (sandboxed renderer + codegen'd ipc/preload)
  • mobile — sqlite via tauri-plugin-sql, talking to rust
provider stack GeneralStore to Auth to SharedContext with Store, LLM, TTS, changePage, Link injected from the side

diagram to drop in: public/logs/one-codebase-ports.webp — provider stack (GeneralStoreAuthSharedContext) 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

  1. Chat in packages/ui calls llm.generateChat(...)
  2. HybridLLM sees a non-cloud key, delegates to DesktopLLM
  3. ipc through preload into main → ollama child process
  4. tokens webContents.send back; store.upsertConversation persists over the same bridge to json on disk

web, cloud model

  1. same Chat component
  2. HybridLLM routes to cloudLLM
  3. http hits apps/web api routes → postgres
  4. WebStore.upsertConversation writes dexie

mobile

  1. same Chat component
  2. cloud/proxy path (local llm is a stub — phones are not the local-70b machine)
  3. fetch is the tauri http plugin (webview cors is strict)
  4. MobileStore.upsertConversation is INSERT OR REPLACE in sqlite

the ui file does not change

one send from shared Chat splitting into desktop IPC/Ollama, web HTTP/Postgres+Dexie, mobile Tauri HTTP/SQLite

diagram to drop in: public/logs/one-codebase-send.webp — one "send" from shared Chat splitting 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 + externalDir or the app router refuses files outside apps/web
  • pnpm hoisted linker keeps electron forge and native .node addons alive at install time; forge's prune still deletes hoisted packages from the asar — budget a packageAfterPrune copy-back or watch your tts .dylib vanish 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

  1. pnpm workspace + hoisted linker + react overrides. get install boring before you write a screen
  2. turborepo with ^generate / ^build, persistent dev, and platform flags in globalEnv
  3. a packages/ui that only imports react and your port types. deep imports. no barrel file. tailwind content globs that include that package
  4. ports, not ifdefs. Store, LLM, TTS, LinkComponent, changePage, useUser. inject them at the app root
  5. compile-time IS_ELECTRON / IS_MOBILE for bundler concerns. runtime appPlatform() for product concerns
  6. 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