One Codebase, Vite React to Five Platforms: Shipping Web, iOS, Android, macOS & Windows

I’ve been shipping web apps for years, and every time a client asked “can we get this on the App Store?” I used to get a bit stressed. This used to be a more complex request. Multiple repos. Multiple dev teams handling the apps. Increasing costs. Increasing number of areas to get bugs.
And have fun rolling out a small UI change across all platforms…
The system I now use makes all of this a walk in the park. Not “we made it work with some duct tape”, now its the whole thing shipped to five platforms off a single build, and the hardest part wasn’t the architecture, it was about fifteen gotchas nobody writes down.
This is that architecture, and those fifteen gotchas.
Quick honesty upfront: this is a webview approach. If you’re building something that lives or dies on 120fps gesture work, go native. That is still the correct flow. But if you’re building a business app, forms, tables, lists, data, then this is the highest-leverage setup I know of.
Ship to five platforms for roughly the maintenance cost of one, and you’ll push JavaScript to phones without asking Apple’s permission every time.
Tools Used
A lot of tools show up in this post, and they each do one job. If you already know them, skip to the next section. If you don’t, here’s the whole cast in one place so nothing below reads as a mystery:
- Vite is the build tool and dev server. It bundles the web app into a
dist/folder and gives you a fast save-and-reload loop while you work. Everything here starts life as a plain Vite web app. - React & React Router are the app itself, the interface and the client-side routing. React Router is the piece that decides which screen a given URL maps to.
- Capacitor is the mobile shell. It wraps the same web build in a native iOS and Android app, dropping your
dist/folder into a webview and handing it native APIs like the camera and push notifications. - Tauri is the desktop shell, the same idea for macOS and Windows. It puts the web build in a native window using the operating system’s own webview.
- Capgo is the over-the-air update service. It hosts the web bundle on a CDN so an installed app can pull a fresh version on launch, which is how a JavaScript fix reaches a phone without a store review.
- Codemagic is a second CI provider that rents cloud Macs and Windows machines, because a macOS or Windows binary can only be built on that operating system.
A build tool, a UI layer, two shells, an update service, and a build farm. Keep those in your head and the rest of this post is just the wiring that connects them.
Capacitor and Tauri Are Just Shells
Everything in this post falls out of one idea, so let me put it first. Capacitor and Tauri are not build targets. They’re shells.
They embed a webview, point it at your dist/ folder, and hand it a native toolbox. They do not rebuild your app. They don’t compile it into something else. Your web bundle is the app, the shells just give it a native window and access to the camera.
pnpm build
│
▼
┌───────────────────┐
│ dist/ │
│ ├── index.html │ ← redirect stub → /app/
│ └── app/ │ ← the real SPA
└───────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
Static host Capacitor Tauri
(Vercel) webDir: 'dist' frontendDist: '../dist'
▼ ▼ ▼
Browser iOS + Android macOS + Windows
│ │
▼ ▼
Capgo OTA CDN App Stores
Three things fall out of that, and they’re the three things people get wrong:
- One artifact serves all five targets. There’s no “mobile build” and “desktop build”. There’s a build.
- The platform differences are three env vars and one router swap. Not three codebases. Not a monorepo of platform packages. Three env vars.
- A JavaScript change can reach a phone without an app store review. That one quietly changes your entire release cadence, and we’ll come back to it.
Three Systems from One Project
Here’s the decision the whole thing hangs on. It’s about twenty lines of code and I stumbled into it solving a completely unrelated problem.
In vite.config.ts:
export default defineConfig({
base: '/app/',
build: { outDir: 'dist/app' },
})
Serving the app under a path prefix instead of the domain root is what lets a marketing site and the product share one origin. example.com is marketing. example.com/app/ is the app. If you think you don’t need that, you will, and retrofitting it means rewriting every URL you’ve ever shipped.
But the prefix creates a problem: anyone landing on / gets a 404. So I wrote a tiny Vite plugin that emits a redirect stub at build time:
// scripts/vite-plugin-redirect-stub.ts
const stubHtml = (target: string): string => {
// Redirect to `${target}index.html`, NOT just `${target}`.
// Android's WebView won't auto-resolve a directory to index.html.
const resolved = target.endsWith('/') ? `${target}index.html` : target
return `<!doctype html>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0;url=${resolved}">
<title>Redirecting…</title>
<script>location.replace(${JSON.stringify(resolved)} + location.search + location.hash)</script>
`
}
export const redirectStub = (options: { target: string; outFile: string }): Plugin => ({
name: 'redirect-stub',
apply: 'build',
closeBundle() {
mkdirSync(dirname(resolve(options.outFile)), { recursive: true })
writeFileSync(resolve(options.outFile), stubHtml(options.target))
},
})
Which produces this:
dist/
├── index.html ← 200-byte redirect stub → /app/index.html
└── app/ ← the real SPA
And that shape happens to be exactly what all three consumers want.
A static host serves dist/, visitors at / bounce to /app/. Capacitor points webDir: 'dist' at it and its WebView cold-boots into the stub, redirects, loads the SPA. Tauri points frontendDist: '../dist' at it but aims its window straight at app/index.html, skipping the stub entirely so there’s no redirect flash on launch.
One build. Three consumers. Zero platform-specific build modes.
A Couple of Router Rules
Rule one: split the route table from the router instance
// src/router.tsx - pure data. Node-safe. Touches no `document`.
export const routes = [
{
Component: RootLayout,
children: [
{ index: true, Component: HomePage },
{ path: 'products', Component: Products },
{ path: 'products/:id', Component: ProductEdit },
],
},
]
// src/main.tsx - browser only. Builds the live router.
const isTauri = import.meta.env.VITE_BUILD_TARGET === 'tauri'
const createRouter = isTauri ? createHashRouter : createBrowserRouter
const router = createRouter(routes, { basename: isTauri ? undefined : '/app' })
createBrowserRouter reads document at construction time. Leave it in router.tsx and every Node process that touches your route table (build scripts, tests, prerendering) dies on import.
Keep the table as inert data and you get something better: the same array feeds Node and the browser. Server matching and client matching can’t drift, because there’s only one table. Most routing bugs are really “two things disagreed about what a URL means”, and this removes the opportunity.
I wrote a piece here about how to correctly add SSG to Vite React in a way that actually works. So wont bang on about this any more.
Windows/MacOs needs HashRouter, with no basename
Tauri’s embedded file server has no SPA fallback. Under BrowserRouter, a desktop user who hits refresh on /app/products gets a 404, Tauri looks for a file at that path and finds nothing. HashRouter routes on the fragment (#/products), which never reaches the server. Refresh-safe.
Gotcha: HashRouter must have no basename, and this isn’t a style preference. The fragment never carries the
/appasset base. Setbasename: '/app'and the empty initial hash resolves to/, react-router’sstripBasenamereturnsnull, no route matches, and your desktop window renders blank. No error. No warning. Clean logs. Just a white rectangle where your app should be.
Four characters of config. Easily the worst hour of the project.
The good news: navigate() is basename-relative on both, so deep links resolve identically everywhere despite the difference. In desktop DevTools you’ll see tauri://localhost/app/#/products.
Why Not Next JS? Surely That DX Would Help?
This is the question I get every single time, and the answer isn’t SSR.
Two things get conflated constantly, so let me pull them apart:
- Dynamic params –
[id],[...slug]. A statically declared route with a variable segment. Next does this perfectly well. Not what I’m talking about. - Dynamic URL routing – the route table itself is a data structure my code owns. Which routes exist, and which router resolves them, are decisions made in code.
Next’s App Router gives you the first and structurally cannot give you the second. Routes are declared by the filesystem and resolved by the framework. That’s a genuinely good design for a server-rendered app (it’s the entire point of the thing) but it means there is no route table for me to pick up and pass around.
The folder structure is the table. The framework is the router. Look at what this app actually does with that array:
| Target | Resolver | Basename |
|---|---|---|
| Web | createBrowserRouter | /app |
| Capacitor (iOS/Android) | createBrowserRouter | /app |
| Tauri (macOS/Windows) | createHashRouter | none |
One definition. Two different router implementations. Chosen by an env var at build time. When your routes are a plain array, that’s four lines of code. A filesystem cannot be passed as an argument, and there is no [...slug] that turns your app/ directory into a hash router inside a Tauri window.
Could I get closer with a static export, or by dropping back to the Pages Router? Sure. But then I’m carrying a large framework specifically to disable the parts of it I’m paying for, in order to arrive at… a static SPA. That’s a lot of ceremony to end up where Vite starts.
And the plainer reason, which I’ll just say: Vite is a materially faster dev experience. Cold start, HMR, the whole loop. When five platforms all boot off one dev server, that loop is where you live all day.
It is just so so nice to work with.
Three Env Vars Cover Your Entire Platform Layer
That’s it. That’s the abstraction.
VITE_BUILD_TARGET(taurior unset) – functional. Flips the router.VITE_PLATFORM(web/ios/android/mac/windows) – analytics labels, and compliance gating.VITE_APP_ENVIRONMENT– Sentry and PostHog tagging.
Stamp them in your package scripts, never in .env.local:
"build:ios": "cross-env VITE_PLATFORM=ios pnpm build",
"build:android": "cross-env VITE_PLATFORM=android pnpm build",
"build:mac": "cross-env VITE_BUILD_TARGET=tauri VITE_PLATFORM=mac tauri build",
The bit that turns out to be a compliance mechanism
Apple’s anti-steering rules forbid a Mac App Store build from selling subscriptions through a web payment flow. So the Mac build has to not do that. The obvious approach is a runtime check: if (isMacDesktop) hidePaywall().
Not good enough. And the reason is lovely:
export const buildPlatform = import.meta.env.VITE_PLATFORM ?? 'web'
export const isMacDesktop = buildPlatform === 'mac'
Vite statically inlines import.meta.env.VITE_PLATFORM. That makes isMacDesktop a compile-time constant. Which means every branch behind it gets dead-code-eliminated, the web-paywall code is stripped out of the Mac binary entirely. Not hidden. Not disabled. Not there.
A runtime check leaves that code sitting in the shipped binary. When a reviewer greps your bundle, that difference is the whole ballgame. The env stamp isn’t a cute optimisation, it’s how you pass review.
Gotcha: detect Tauri v2 via
__TAURI_INTERNALS__, not__TAURI__. The latter only exists whenwithGlobalTauriis on, which is off by default in v2. Key off it alone and your packaged desktop app confidently identifies itself as a web browser.
Capacitor: The One Call You Must Never Delete
// capacitor.config.ts
const config: CapacitorConfig = {
appId: 'com.example.app',
webDir: 'dist', // ← the SAME dist/
plugins: { CapacitorUpdater: { autoUpdate: true } },
}
webDir: 'dist' is the payoff from earlier. The redirect-stub shape is exactly what Capacitor’s WebView needs. No separate build mode. No config branch. Nothing.
Three things bite here.
notifyAppReady() is a dead-man’s switch
export const capacitorInit = (): void => {
if (!Capacitor.isNativePlatform()) return
CapacitorUpdater.notifyAppReady() // ← DO NOT REMOVE
// …status bar setup
}
Gotcha: Capgo auto-rolls-back to the previous bundle if this isn’t called within ~10 seconds of launch. Delete it (or let it get stuck behind a slow
await) and every release looks broken to every user. They’ll silently keep running last week’s bundle. No crash. No error. Nothing in your dashboard. Just a product that mysteriously never updates.
Call it early, call it unconditionally. Then deliberately break it once: ship a build without it, watch Capgo roll back, put it back. Now you actually trust the safety net instead of hoping.
Never run a bare cap sync
It runs a plain pnpm build, which doesn’t stamp VITE_PLATFORM, so the bundle you just pushed to a real iPhone reports itself as web in every analytics event you’ll ever collect. Always go through the per-platform script.
Set up live reload on a real device
Two terminals. pnpm dev in one, cap run android --live-reload --host 0.0.0.0 --port 5173 in the other. The native build picks up your LAN URL and hot-reloads on save.
It’s the biggest quality-of-life win in mobile development and almost nobody bothers.
And what OTA can’t ship: a new native plugin, a permission change, a URL scheme, app icons, a Capgo plugin bump. Those still need a store submission. Everything else (every JS change, every UI tweak, every logic fix) goes over the air. Apple (Review Guidelines 3.3.2 / 2.5.2) and Google both explicitly allow this, provided you’re not changing the app’s core purpose.
Tauri Quirks
{
"build": {
"devUrl": "http://localhost:5173/app/", // ← the /app/ suffix is REQUIRED
"frontendDist": "../dist" // ← the SAME dist/
},
"app": {
"windows": [{
"url": "app/index.html", // ← skips the redirect stub
"visible": true, // ← see below
"backgroundColor": "#ffffff"
}]
}
}
Gotcha:
devUrlmust include/app/. Vite serves underbase: '/app/'and 404s at the root, point Tauri there and your desktop window proudly renders Vite’s “did you mean /app/?” error page.
Now the good one
The intuitive way to kill a white flash on launch is: open the window hidden, render, call show() after first paint. Clean. Obvious. I built it.
Don’t.
Gotcha: macOS WKWebView does not render while its window is hidden. So the paint that was supposed to trigger the reveal can never happen. The window stays hidden. Forever.
Logs are clean. JS runs fine.show()is never reached. The app simply never appears. It’s a genuine deadlock, and it only happens on macOS, so your Windows testing passes.
The fix is boring and works everywhere: open the window already visible, with a background colour that matches your app. Nothing to hide, so nothing flashes. I deleted an elaborate reveal dance in favour of two lines of config and the app got better.
Two entitlements files, on purpose
Gotcha: TestFlight rejects builds missing
com.apple.application-identifierandcom.apple.developer.team-identifier(App Store Connect error 90886). But those are restricted entitlements, a locally ad-hoc-signed build carrying them won’t launch at all.
So you need two files. One for local builds, a CI-only superset for submission. Add any new entitlement to both.
And the rest, quickly: Windows uses WebView2 (Chromium), macOS uses WKWebView (Safari). Tailwind and standard React are fine, but Chrome-only behaviour drifts on Mac, smoke-test macOS before every release. Also: don’t wire an in-app updater. Both stores own auto-updates, and self-updating binaries are disallowed by Apple anyway.
Use CapGo To Ship JavaScript Without Asking Apple
Capgo hosts your web bundle on a CDN and serves it to installed apps by channel. The native shell checks on launch and silently pulls the latest bundle.
That’s the mechanism that turns “ship a fix” from a three-day store review into a thirty-second CI job. It is, genuinely, the best part of this whole setup.
Four channels, not two
npx @capgo/cli@8 channel create ios-production
npx @capgo/cli@8 channel create android-production
npx @capgo/cli@8 channel create ios-staging
npx @capgo/cli@8 channel create android-staging
Why four? Because VITE_PLATFORM is baked into each bundle at build time, an iOS bundle and an Android bundle are different artifacts. Capgo serves one “latest” per channel, they can’t share.
The suffix rule
Gotcha: bundle versions are unique per app, not per channel. Both platforms live under one Capgo app, so two uploads in one release collide unless you suffix the platform. And the shape differs by track, because semver:
- Staging – already carries
-staging.N, so the platform is a trailing dot:1.0.0-staging.22.ios- Production – a clean release, so the platform is a hyphen prerelease:
1.0.1-ios. A dot (1.0.1.ios) is invalid semver and gets rejected.
The silent one that ships nothing
This is the worst bug in the stack, because it’s invisible.
# ❌ Uploads the bundle... and may leave it unassigned to the channel.
npx @capgo/cli@8 bundle upload --channel ios-staging --bundle "$VERSION"
Gotcha:
bundle upload --channeldoes not reliably promote the bundle to the channel’s served version. Two causes: an upload-scoped API key can push a bundle but can’t write the channel link, and a major-version jump over thebuiltin0.0.0blocks auto-promotion.
Either way: green CI, a bundle visible in the console, and not one device receives it.
Always follow the upload with an explicit promote, under set -e so a failed link fails loudly:
set -e
npx -y @capgo/cli@8 bundle upload --apikey "$CAPGO_TOKEN" --channel ios-staging --bundle "$VERSION"
npx -y @capgo/cli@8 channel set ios-staging "$APP_ID" --apikey "$CAPGO_TOKEN" --bundle "$VERSION"
Use a write-scoped key. An upload-only key can’t set channels.
Gotcha: pin the CLI major version (
@capgo/cli@8), never@latest. A@latestbuild broke my staging publish when a new release renamed--bundle-versionto--bundle. Pin it, and read--helpbefore you ever raise the pin.
And the sequencing thing nobody mentions
OTA only reaches devices that already installed a native build.
You can’t skip the store submission. You can only make it the last one you ever do for that release train. Wire Capgo and CI today (free, no accounts). Get Apple and Google accounts now, verification takes days. Submit v1. From then on, OTA reaches everyone automatically.
Codemagic: Why You Need a Second CI
One constraint drives this whole section: you cannot cross-compile. macOS binaries need a Mac. Windows binaries need Windows. And most CI providers (Bitbucket included) have no hosted macOS runner.
So Codemagic gives you cloud Macs and Windows boxes, and your main CI triggers it over its REST API. One orchestrator, no double-builds.
macOS: every step is a rejection code
The shape is simple: import certs into a temp keychain, build a signed universal .app, wrap it in a .pkg, upload with altool.
I’m not pasting sixty lines of bash at you. Here’s the honest summary instead: every single step in that script exists because leaving it out produced a specific App Store Connect rejection.
- Export your p12 with
openssl pkcs12 -export -legacy. macOSsecurity importrejects OpenSSL 3’s default encryption. The error message does not tell you this. - Import the Apple WWDR G3 intermediate. Without a validating chain,
productbuildcannot see your identity at all, it says “no identity found” while the identity sits right there in the keychain. - Debug with
security find-identity -p basic. Plain-vfilters to codesigning EKUs and hides the installer identity, so you’ll swear the import failed when it didn’t. - Export
APPLE_SIGNING_IDENTITY. Tauri only applies entitlements when it signs, and it only signs when this is set. Without it: no sandbox, rejection 90296. - Tauri doesn’t embed the provisioning profile. Copy it into the bundle and re-sign so the seal covers it. Skip it: rejection 90889.
- Strip prerelease suffixes from
CFBundle*. Apple rejects non-numeric version parts, so1.0.3-staging.52has to become1.0.3.
Budget a day for this. Everyone does.
Windows: the best deal in desktop distribution
Then you get to Windows, and it’s six lines of YAML:
desktop-windows:
instance_type: windows_x2
# NO signing group. No secrets at all.
scripts:
- pnpm tauri:windows:build --arch x64,arm64
artifacts:
- src-tauri/target/msix/*.msixbundle
The Microsoft Store signs your MSIX on ingestion. No Authenticode certificate. No annual renewal. No SmartScreen “unknown publisher” warning frightening off your users. No signing secrets in CI.
Genuinely the best deal in desktop distribution and I have no idea why more people don’t talk about it.
The Pipeline: Build It Before You Own a Single Account
Branch model:
- PRs /
development– checks only. Ships nothing. staging– checks → E2E boot smoke → manual ▶ gate → publish staging.main– fully automatic. Version, tag, publish everywhere.
Two ideas here are worth stealing whatever your stack.
Guard every external action on its secret
if [ -n "$CAPGO_TOKEN" ]; then
# …publish
else
echo "CAPGO_TOKEN not set - skipping Capgo"
fi
It means you write the entire pipeline on day one (Capgo, Codemagic, tagging, all of it) and it runs green and ships nothing.
Then every secret you add switches on one more leg of the rollout. You prove the orchestration works with zero accounts, zero devices, and zero dollars, and you debug each leg in isolation as it comes online, instead of flipping everything on at once and staring at a wall of red.
The Gotcha List
The actual value of this post. Bookmark this bit.
Build & routing
strictPort: true, or a stale dev server makestauri devsilently load a zombie.- HashRouter takes no basename – with one, no route matches and the desktop window renders blank.
- Redirect to
${target}index.html, not${target}– Android’s WebView won’t resolve a directory. devUrlmust include/app/– Vite 404s at the root.- The route table must be import-safe in Node –
createBrowserRoutertouchesdocumentat construction.
Platform detection
- Detect Tauri v2 via
__TAURI_INTERNALS__–__TAURI__is off by default in v2. - Compliance gates must be build-time constants – a runtime check leaves the forbidden code in the binary.
Capacitor
notifyAppReady()within ~10s or Capgo rolls back – omit it and every release looks broken.- Never
cap syncbare – it mis-stampsVITE_PLATFORMaswebon a real device build. - Cold-launch deep links need
getLaunchUrl()– a killed app never firesappUrlOpen. - Android
versionNamemust be strictx.y.z– Gradle’s"1.0"default makes Capgo reject the device.
Tauri
- macOS WKWebView doesn’t render while hidden – hidden-then-show-on-paint is a deadlock. Open visible.
- Two entitlements files – TestFlight requires identifiers (90886) that stop a local build launching.
- Capabilities compile into the Rust binary – restart
tauri devafter editing them. - Smoke-test macOS – WKWebView drifts from WebView2.
Capgo
bundle upload --channeldoesn’t reliably promote – always follow withchannel set --bundle, underset -e.- Your token must be write-scoped – upload-only keys can’t set channels.
- Suffix the platform onto the bundle version – dot for staging, hyphen for clean releases.
- Pin the CLI major version –
@latestrenamed a flag and broke a publish.
Signing
- Export p12 with
-legacy– macOS rejects OpenSSL 3’s default encryption. - Import the WWDR G3 intermediate – or
productbuildcan’t see your identity at all. security find-identity -p basic– plain-vhides the installer identity.- Tauri doesn’t embed the provisioning profile – copy it in and re-sign (90889).
- Export
APPLE_SIGNING_IDENTITY– Tauri only applies entitlements when it signs (90296). - Strip prerelease suffixes from
CFBundle*– Apple rejects non-numeric versions.
But Get the Foundation Right First…
If you take one thing from this, don’t take the gotcha list. Take this:
Almost none of the hard work was platform-specific. There’s no iOS code in this app. No Android code. No macOS code. There’s a Vite config, a route table that’s a plain array, three environment variables, and a pipeline that’s mostly if statements.
The platforms came almost for free. What didn’t come free was the foundation: the dist/ shape, the route table being a value rather than a folder, the version living in one place.
Get those three things right at the start and the five platforms are a weekend. Get them wrong and you’ll be unpicking them for months, because every one of them is load-bearing for all five targets at once.
Spend the time on the foundation. Then let the shells do their job.


