Skip to content

Repository files navigation

Universal App Template

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.

Why opinionated

  • 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.md and AGENTS.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.

Platforms

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.

The stack (decided)

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.

Enforced architecture

pnpm check runs the full gate:

  • arch: dependency-cruiser. The frontend flows components/lib → features → app, downward only, with features isolated from each other; on the backend domain stays pure and infra/http depend on it, never the reverse.
  • typecheck · lint · test: tsc, Biome, the API behaviour suite, and the tamper tests behind security, template and 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.

Built today

  • 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:check failing 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 clean domain / infra / http split (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.

Structure

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

Make it yours

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 changed

It 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_lib and stays that way. main.rs calls 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 first cargo build.
  • src-tauri/gen/ holds the generated Android and Xcode projects, built around the previous identifier. Delete it after renaming and re-run pnpm 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.

Development

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 build

pnpm 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.

Android toolchain

The Android target builds with exactly this set:

  • JDK 17 (Temurin), required by both sdkmanager and the Android Gradle Plugin. Set JAVA_HOME.
  • Android SDK via command-line tools: platform-tools, platforms;android-34, build-tools;34.0.0, ndk;26.3.11579264. Set ANDROID_HOME to the SDK root and NDK_HOME to the NDK directory, and accept licenses once with sdkmanager --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.json version must be 0.0.1 or higher, because Android rejects 0.0.0 at build time.
  • Developer Mode must be on (Settings → System → For developers). The build symlinks the compiled .so into 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).

Dynamic ports (run many clones at once)

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).

Security

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.json grants no plugin permissions to the webview. Native calls go through the app commands in commands.rs only.
  • Web: the production build emits dist/_headers (Netlify / Cloudflare Pages format) with the CSP plus HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy and the cross-origin headers. On other hosts, translate it: vercel.json headers, an nginx add_header block, 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.

Shipping needs accounts no template can ship

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 puts keystore.properties there and edits app/build.gradle.kts to read it, and both are wiped by the next tauri 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_KEY signs update manifests, no updater is configured, and its custody rules are in ARCHITECTURE.md.

Example files

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.

License

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.

About

Highly opinionated, security-hardened, machine-guarded template: one TypeScript codebase → web, desktop (Win/macOS/Linux), iOS, Android & PWA. Tauri v2 + React SPA, type-safe end to end, architecture enforced by tooling (dependency-cruiser · knip · jscpd).

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages