Packaging Beyond the Browser
PWA first. Native shells only when a concrete requirement demands one — each shell is a build pipeline, a signing process and a review queue you now own.
The ladder — stop at the lowest rung that works
| Rung | Use | Cost |
|---|---|---|
| PWA | Default. Installable, offline, one deploy, instant updates. | None beyond the web app |
| Capacitor | App Store / Play presence is required, or a native API the web lacks (background sync, push on iOS, deep OS integration). | Two store accounts, signing, review delays |
| Tauri | Genuine desktop needs — filesystem access, OS integration, offline install, a system tray. | Rust toolchain, per-platform builds, code signing |
Rules for agents
- The web build stays the single source. Capacitor and Tauri wrap it; they never fork it.
- Native capability goes behind a port with a web fallback, so the PWA still works —
../patterns/hexagonal-architecture.md. - No platform conditionals scattered through components. Detect once, inject the right adapter at the composition root.
- Know the constraints before promising: iOS Safari limits background execution and can evict storage; app stores reject thin web wrappers that add nothing native.
- Adding a shell means adding signing keys, store metadata and a release process. Do not start one casually.
Deviate when
The app genuinely needs native performance or deep platform APIs throughout — at that point it is a native app, not a wrapped web app. Say so rather than fighting a shell.
Smells
Capacitor added with no store requirement, if (isNative) through the component tree, a desktop build that has diverged from web, native plugins called directly from React, a shell that ships a stale bundled web build.