An opinionated, machine-guarded starter for building one TypeScript app that runs
on web, desktop (Windows/macOS/Linux), iOS, Android, and as an installable PWA,
all from a single shared frontend. pnpm check fails the build on a crossed layer
boundary, a source file over 500 lines, duplicated logic, dead code, a widened
CSP, or a half-renamed clone.
The stack is decided and the architecture is enforced by tooling rather than by documentation. That is the trade: you inherit strong defaults and a paved road that keeps an AI or a team from degrading the structure as it grows, and you give up the freedom to swap the pieces out.
- One toolchain, four platforms. Tauri v2, chosen over Electron and Capacitor, is the single native shell for desktop and mobile; the web target is the same SPA with no shell. One core, one frontend.
- Structure the build enforces. dependency-cruiser holds the layer
boundaries, and the pre-push hook runs it. See
ARCHITECTURE.mdandAGENTS.md. - Type-safe end to end. TypeScript everywhere, one shared type package, Zod at the edges, and IPC bindings generated from the Rust commands. The compiler is the contract.
| Surface | How | Offline |
|---|---|---|
| Web | Static SPA, deployed directly | via PWA |
| PWA | Installable web app (service worker) | yes |
| Desktop | Tauri v2 (Windows, macOS, Linux) | planned |
| iOS / Android | Tauri v2 mobile (one project) | Android builds + runs; iOS needs a Mac |
A single React SPA renders on every surface. Tauri provides desktop and mobile from one Rust core, so bundles stay small.
| Layer | Choice |
|---|---|
| Shell | Tauri v2 |
| Frontend | React 19, TypeScript, Tailwind v4 |
| Build tool | Vite 8 (Rolldown) |
| UI | shadcn-style components on Base UI |
| State / data / forms | Zustand, TanStack Query, Zod, react-hook-form |
| Backend | Hono |
| ORM / data | Drizzle + libSQL (local) ⇄ Turso (cloud) |
| Monorepo | pnpm workspaces (catalogs) + Turborepo |
| Lint/format · hooks | Biome · lefthook |
| Guards | dependency-cruiser · jscpd · knip · file-size cap |
Roadmap layers keep the same discipline: Auth (Better Auth), Payments (Stripe + RevenueCat), push (APNs/FCM), i18n, observability.
pnpm check runs the full gate:
arch: dependency-cruiser. The frontend flowscomponents/lib → features → app, downward only, with features isolated from each other; on the backenddomainstays pure andinfra/httpdepend on it, never the reverse.typecheck·lint·test: tsc, Biome, the API behaviour suite, and the tamper tests behindsecurity,templateand the bundle secret scan. Each of those breaks a control and asserts the guard fails.size: no source file over 500 lines (ideal ~150).dup: jscpd. No copy-pasted logic.knip: no unused files, deps, or exports.security: the Tauri webview stays locked down (see below).template: the app has one identity, spelled the same way everywhere, and states its licence once (see Make it yours).bindings:check: the generated IPC bindings still match the Rust commands.
The pre-push hook runs all of it except bindings:check, which needs the Rust
toolchain a frontend-only clone can do without. That one belongs to CI, and CI
does not exist yet. See the open items in SECURITY.md.
Add a feature with pnpm gen feature, which emits a correct slice.
- Monorepo with the full gate green; both frontends build; the Tauri Rust core compiles; Android builds, installs and runs on a device.
- Typed IPC: three Rust commands, TypeScript bindings generated from them by
tauri-specta and committed, with
pnpm bindings:checkfailing on drift. - Shared frontend (
packages/client) with a P1 design system: theme (light/dark/ system), buttons/inputs/card/badge/spinner, layout primitives, app shell and page header, loading/empty/error states, a Zod-driven form kit, and toasts. - Hono API (
apps/api) with a cleandomain / infra / httpsplit (health and echo). - Example data layer (
packages/db): Drizzle + libSQL, with example files marked for you to delete or replace. Nothing creates a schema for you.
apps/
web/ # React SPA + PWA (Vite)
shell/ # Tauri v2 native shell (desktop + iOS + Android)
api/ # Hono backend (domain / infra / http)
packages/
client/ # shared frontend (components, lib, features, app)
db/ # Drizzle schema + libSQL client (example only)
types/ # shared types (client <-> api contracts)
config/ # shared tsconfig
One command renames the clone across both toolchains:
pnpm rename "Acme Notes"
pnpm rename "Acme Notes" --identifier com.acme.notes --author "Acme Inc"
pnpm install # the workspace name changedIt writes every identity site from the one display name: the workspace and
crate names, the Cargo description and default-run, the Cargo.lock entry, the
Tauri product name, window title and bundle identifier, both index.html
titles, the PWA manifest, the in-app brand and the README title. Without
--identifier it derives a placeholder (com.example.acmenotes) that is
obviously yours to replace before publishing.
Two things it deliberately does not touch:
- The Rust lib crate is called
app_liband stays that way.main.rscalls it by name, and a lib renamed out of step with that call leaves the app with no entry point: it does not compile, and nothing says so until the firstcargo build. src-tauri/gen/holds the generated Android and Xcode projects, built around the previous identifier. Delete it after renaming and re-runpnpm mobile:android/pnpm mobile:ios; otherwise a device installs the renamed app alongside the old one instead of replacing it.
pnpm template (part of pnpm check) reads all of it back and fails if any
site disagrees with the product name, if main.rs calls a crate the Cargo
manifest does not define, if Cargo.lock has not learned the crate's name, if
the bundle identifier is something Android or macOS rejects, if a workspace:*
dependency resolves to nothing, if the repo carries no LICENSE or states two
different licences, or if a generated native project is left on the old identity.
Half-renamed clones fail the gate instead of failing a build later.
Prerequisites: Node 22+, pnpm 10+, and the Rust toolchain for desktop/mobile (see Tauri prerequisites).
pnpm install
cp .env.example .env
pnpm dev # api + web + shell-vite together
pnpm dev:web # api + web SPA
pnpm dev:api # api only
pnpm desktop # launch the Tauri desktop window (starts api + shell itself)
pnpm mobile:ios # iOS simulator (first run: pnpm --filter @repo/shell tauri ios init)
pnpm mobile:android # Android emulator (first run: pnpm --filter @repo/shell tauri android init)
pnpm ports # print this clone's derived ports/URLs
pnpm stop # stop everything this clone started (dev servers + app window)
pnpm rename "Acme Notes" # make the clone yours (see above)
pnpm check # typecheck + test + lint + arch + size + dup + knip + security + template + bindings
pnpm build # production buildpnpm desktop is self-contained: it starts the API and the shell Vite server
itself, then opens the native window pointed at them. Same for pnpm dev and
pnpm dev:web. Ctrl+C stops them, and pnpm stop clears anything left over,
including the desktop window, which holds no port.
The Android target builds with exactly this set:
- JDK 17 (Temurin), required by both
sdkmanagerand the Android Gradle Plugin. SetJAVA_HOME. - Android SDK via command-line tools:
platform-tools,platforms;android-34,build-tools;34.0.0,ndk;26.3.11579264. SetANDROID_HOMEto the SDK root andNDK_HOMEto the NDK directory, and accept licenses once withsdkmanager --licenses. - Rust Android targets:
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android.
Two Windows-specific traps, both fatal:
tauri.conf.jsonversionmust be0.0.1or higher, because Android rejects0.0.0at build time.- Developer Mode must be on (Settings → System → For developers). The
build symlinks the compiled
.sointo the Gradle project and Windows denies symlink creation without it.
tauri android build --debug --target aarch64 produces the device APK;
add --target x86_64 for an emulator image, since the emulator runs the
host architecture. Artifacts land under
apps/shell/src-tauri/gen/android/app/build/outputs/. iOS requires a Mac
with Xcode and remains unproven until that hardware exists.
Environment variables have a hard public/private boundary. A VITE_ prefix
means public, permanently, on every platform: Vite inlines it into the bundle,
which is served to every browser and unpacked from every app-store binary.
Everything else (DATABASE_URL, TURSO_AUTH_TOKEN, and so on) stays
server-side and must never gain the prefix to "make something work": move the
call behind the API instead. Three guards enforce this. pnpm security rejects
any widened envPrefix in the Vite configs, pnpm build scans the actual
output for secret shapes and for the values of non-VITE_ variables, and the
pre-commit hook runs gitleaks over the staged diff (skipped with a warning if
not installed).
Ports are derived, never fixed. scripts/ports.mjs hashes the repo path into a
unique block of four (api, web, shell, shell HMR), so every clone of this
template gets its own non-overlapping ports and several can run side by side with
no collisions. pnpm ports shows the plan; the launchers inject them into the
API (PORT), Vite (*_PORT), and the frontends (VITE_API_URL) automatically.
Nothing is hardcoded to a port number.
Blocks land in 10000..31990, clear of the ephemeral range every supported OS
allocates from and of the ranges Windows reserves for Hyper-V and WSL.
The block is probed before use: if anything already holds one of the four ports,
the run shifts to the next free block and says so. Probing never changes the
answer when the ports are free, so your URLs stay the same run to run. The block
a run settles on is recorded in .dev-ports.json (git-ignored) so pnpm stop
targets the ports actually in use rather than the ones that were derived. Set
DEV_PORT_BASE to pin a block explicitly and skip the search.
Mobile is the same Tauri app as desktop (apps/shell); tauri ios/android init
generates native projects under apps/shell/src-tauri/gen/ (git-ignored).
Every target (desktop, mobile, browser) enforces a Content Security Policy
generated by one function in scripts/csp.mjs, in dev and in production:
default-src 'self', object-src/frame-ancestors/form-action at 'none',
and connect-src as an explicit allowlist.
- Shell:
capabilities/default.jsongrants no plugin permissions to the webview. Native calls go through the app commands incommands.rsonly. - Web: the production build emits
dist/_headers(Netlify / Cloudflare Pages format) with the CSP plus HSTS,X-Content-Type-Options,Referrer-Policy,Permissions-Policyand the cross-origin headers. On other hosts, translate it:vercel.jsonheaders, an nginxadd_headerblock, or your CDN's rules. - API: CORS is an origin allowlist read from
ALLOWED_ORIGINS. In production the API refuses to start if it is unset; in dev it is filled in automatically with the ports above. Secure headers, CSRF protection, a body limit and a request timeout are on by default.
pnpm security (part of pnpm check) fails if any of this is loosened: a
disabled or edited CSP, a wildcard source, a capability grant outside the
allowlist. pnpm security --print shows every policy.
Dev servers stay off the network. Vite and the API bind loopback; only
on-device mobile dev needs more, so setting TAURI_DEV_HOST widens both binds
and prints exactly which ports just became reachable by everyone on the
network. The dev servers treat that network as hostile either way: explicit
allowedHosts (blocks DNS rebinding), CORS off, and a strict serving
allow-list with .env*, *.pem, .dev-ports.json and src-tauri/ denied by
name. pnpm security also enforces a minimum Vite version, so a downgrade
cannot quietly reintroduce a patched dev-server file-read hole.
Supply chain. pnpm-workspace.yaml turns on pnpm's supply-chain controls
(all opt-in on pnpm 10): a 3-day cooldown before newly published versions
resolve (minimumReleaseAge), publisher trust checks
(trustPolicy: no-downgrade), an empty install-script allowlist
(onlyBuiltDependencies: [], since nothing in the tree needs one), and
lockfile-verified runs (verifyDepsBeforeRun). pnpm audit:deps runs
pnpm audit plus cargo audit/cargo deny against the Rust crate; the cargo
policies live in apps/shell/src-tauri/deny.toml and .cargo/audit.toml, and
every ignored advisory has a written reason and review date. Audits run in CI
and on a schedule rather than in pnpm check, because the advisory database
changes while your code does not. The cargo half needs
cargo-audit and
cargo-deny installed.
When you point the apps at a real backend, set VITE_API_URL at build time
so the web CSP allows that origin (the build warns if it is unset), and add the
origin to connect-src in app.security.csp
(apps/shell/src-tauri/tauri.conf.json) for the desktop app. If you add
anything else that loads from another origin, widen cspDirectives() in
scripts/csp.mjs so dev and production stay in step. See
ARCHITECTURE.md for the full model, and
SECURITY.md for the threat model and how to report a
vulnerability.
pnpm build produces artifacts. Getting them onto somebody else's machine
without a warning dialog needs accounts and certificates issued to a legal
identity, so none of it can live in a repository:
| Target | What it takes | Cost |
|---|---|---|
| Web / PWA | A host that serves dist/ and applies _headers |
hosting only |
| Windows | An Authenticode certificate, private key on a hardware token or an HSM. CI signing means a cloud signing service rather than a .pfx in a secret |
paid, annual |
| macOS | Apple Developer Program, a Developer ID certificate, and notarization. Gatekeeper refuses an unnotarized app | $99/year |
| iOS | The same membership, plus App Store review | included above |
| Android | A Google Play developer account, an upload key you keep, and an AAB. Play does not accept an APK for a new app | $25 once |
| Linux | Nothing signs .deb / .rpm / AppImage by default |
none |
Unsigned still runs. Windows shows a SmartScreen warning until the binary earns reputation, macOS refuses to open the app at all without a right-click override, and neither store accepts an unsigned upload. Desktop side-loading is the one path where "later" is a real answer.
Two things specific to this setup:
- The Android signing config lives in
src-tauri/gen/android, which is git-ignored. Tauri's flow putskeystore.propertiesthere and editsapp/build.gradle.ktsto read it, and both are wiped by the nexttauri android init, which is also what a rename requires. Keep the keystore and its properties file outside the repo and re-apply them after each init. A lost upload key is recoverable through Play support; a lost app signing key, if you opted out of Play App Signing, is not. - The Tauri updater key is a separate secret.
TAURI_SIGNING_PRIVATE_KEYsigns update manifests, no updater is configured, and its custody rules are in ARCHITECTURE.md.
Files marked ⚠️ EXAMPLE (for instance packages/db/src/drizzle-example-repository.ts
and schema/example.ts) exist to show a pattern. They are not wired into the
running app and create no cruft if ignored. Copy the shape into your own code,
then delete them.
MIT, in LICENSE, and declared in package.json and the crate
manifest so scanners read the same answer. It covers the template; what you build
in a clone is yours, under whatever terms you choose. Relicensing means changing
all three, and pnpm template fails if the file goes missing or the two manifests
stop agreeing.