One codebase that ships to iOS, Android, and web, backed by a fully type-safe API. Use this as a GitHub template for new projects.
| Layer | Choice |
|---|---|
| Monorepo | pnpm workspaces + Turborepo |
| Mobile + Web | Expo Router (React Native Web) — one app, 3 targets |
| Styling | NativeWind (Tailwind for RN + web) |
| Design tokens | @elirobinson/tokens — colour, space, radius, type |
| State (server) | TanStack Query via tRPC |
| API | Fastify + tRPC |
| Database | Prisma + Postgres |
| Validation | Zod schemas in @repo/api-contracts |
| Lint/format | ESLint (flat config) + Prettier + Husky + lint-staged |
| Unit/component | Jest + React Native Testing Library |
| E2E (web) | Playwright |
| E2E (mobile) | Maestro |
| CI | GitHub Actions + Turborepo remote caching |
| Deploy | EAS (mobile), Vercel/EAS Hosting (web) |
Suggested next adds (not wired yet): Clerk (or another auth provider), Zustand for client state, React Hook Form for forms.
apps/
mobile-web/ Expo Router app — iOS, Android, and web from one codebase
api/ Fastify + tRPC server, Prisma, router implementation
packages/
ui/ Shared components (NativeWind)
api-contracts/ Shared Zod schemas — the contract between client and server
utils/ Shared helpers (use when you have cross-app pure logic)
config/ Shared ESLint, Tailwind, and tsconfig presets
Ownership to copy when you fork:
- Schemas live in
packages/api-contracts(imported by the API, and by the client when you build forms). - Router + Prisma live in
apps/api. The client type-importsAppRouterfromapi— type-only, so Metro never bundles server code. - Add a procedure in
apps/api/src/router.tsand a schema inpackages/api-contractswhen the input/output shape is shared.
Requires Node 24 (see .nvmrc) and pnpm 11.
The design system package comes from GitHub Packages, so you need a personal
access token with read:packages in your user-level ~/.npmrc:
//npm.pkg.github.com/:_authToken=<your token>
pnpm install
cp apps/api/.env.example apps/api/.env # set DATABASE_URL
pnpm --filter api prisma:generate
pnpm --filter api prisma:migrate
pnpm dev:api # starts the Fastify/tRPC server on :4000
pnpm dev:mobile-web # starts Expo — press i / a / w for iOS/Android/webpnpm lint # ESLint across every app/package
pnpm typecheck # tsc --noEmit across every app/package
pnpm test # Jest unit + component tests
pnpm test:e2e:web # Playwright, against the web build
pnpm --filter mobile-web test:e2e:mobile # Maestro, needs a simulator/device
pnpm format # Prettier write
pnpm tokens:sync # regenerate the Tailwind theme from @elirobinson/tokensColour, spacing, radii and type come from @elirobinson/tokens. Use the utility
that names the token (bg-accent, text-fg-2, rounded-md) rather than a
literal value, and run pnpm tokens:sync after bumping that package. See
AGENTS.md for how the bridge works.
- Rename
mobileweb/com.yourorg.mobilewebinapps/mobile-web/app.json. - Set up EAS (
eas init) for mobile builds/submits. - Point
DATABASE_URLat a real Postgres instance (Supabase, Neon, RDS, etc.). - Wire up auth in
apps/api/src/context.tsand add aprotectedProcedureinapps/api/src/trpc.ts. - Add a
TURBO_TOKEN/TURBO_TEAMsecret in GitHub if you want Turborepo remote caching in CI (optional but speeds PRs up a lot).
Go through this list before you mark the repo "Template repository" on GitHub:
- Run
pnpm installlocally at least once and commit the resultingpnpm-lock.yaml— an unlocked template will drift immediately. - Run
pnpm lint,pnpm typecheck, andpnpm testand confirm all pass clean. - Delete this checklist section (and the "Using this as a template" section above) once you've actually done those steps, or leave it — your call, but don't ship it half-followed.
- Replace every occurrence of
mobileweb/com.yourorg.mobileweb(inapp.json) with your real app name and bundle ID. - Replace
yourorginapps/mobile-web/e2e/maestro/flow.yaml'sappIdtoo. - Decide on a real Postgres provider and update
apps/api/.env.exampleaccordingly (don't commit a real.env— it's gitignored, keep it that way). - Confirm
.gitignorecovers your provider's local artifacts (e.g. add.vercel/,.eas/if those tools generate local config you don't want committed). - If you want Turborepo remote caching in CI, add
TURBO_TOKENandTURBO_TEAMas GitHub Actions secrets — otherwise CI still works, just without cross-run caching. - If you want the Release workflow to actually open PRs, confirm Actions has "Read and write permissions" enabled under Settings → Actions → General → Workflow permissions.
- Add a LICENSE file appropriate for how this template will be reused.
- Smoke-test all three targets once end-to-end:
pnpm dev:webin a browser,pnpm dev:ios/dev:androidin a simulator, andpnpm dev:apiresponding on/health. - Squash/clean the git history so the template's first commit is tidy — nobody forking it needs your scaffolding commits.
- New shared component: add it to
packages/ui/src, export frompackages/ui/src/index.ts. It's usable fromapps/mobile-webimmediately. - New API endpoint: add a Zod schema to
packages/api-contracts/src/schemaswhen the shape is shared; add the procedure inapps/api/src/router.tsand talk to Prisma viactx.users(or a new store onContext). - Need real SSR/SEO for web later: the
ui/api-contracts/utilspackages don't know or care what renders them — you can swap the web target for a Next.js app without touching shared code.