ReadyNative

Module spec

The module.json contract, generated files and ownership rules (docs/module-spec.md)

Every optional thing lives in modules/<category>/<option>/ with module.json, files/ (a mirror of repo-root paths), and docs.md. bun setup copies selected files/** to the root byte-for-byte, deletes files owned only by non-selected modules, generates the files below, patches package/app/tsconfig, strips the demo content (unless --with-examples), drops the readynative:contract-lint entry from eslint.config.js (unless --keep-modules), finalizes the tree (see "Finalize" below, unless --keep-modules) and (unless --keep-modules) removes modules/. Invariant: the committed root tree == setup --preset default --with-examples --keep-modules output (diff empty except lockfile and .readynative.json). The drift check for the dev loop is therefore:

bun run setup --dry-run --preset default --with-examples --keep-modules --yes   # must report only .readynative.json to write

The root keeps the examples and the lint contract on purpose: every UI stack in modules/ has to compile the shipped screens, which is what the no-restricted-imports entry marked // readynative:contract-lint enforces. Buyers get neither - a default setup run removes both.

Finalize (the buyer's tree)

After the strip/ESLint step, and only when --keep-modules is absent, setup runs scripts/lib/finalize.ts (planFinalize → moves / deletes / rewrites / packageJson, then the same reporting writer as the rest of apply). verify-modules.sh always passes --keep-modules, so finalize never runs there and the dev loop keeps the product tree.

  • F1 - components. src/ui/<active stack>/* → src/components/ui/* (flat), plus src/ui/theme-mode.ts and src/ui/__tests__/. src/ui/contract.ts becomes src/components/ui/types.ts - everything after its // readynative:uikit marker (the UiKit type) is cut, the prop types stay because 20 components import them. The stack's index.ts loses import type { UiKit }, the const kit = { … } satisfies UiKit object and its default export, and gains the re-exports the generated src/ui/index.ts carried (./theme-mode, ./types). Every @/ui… reference in src/**, scripts/**, jest.setup.ts, .maestro/** and the root *.ts(x)/js files (comments included) is rewritten: @/ui → @/components/ui, @/ui/contract → @/components/ui/types, @/ui/<stack>/x → @/components/ui/x. jest.setup.ts loses its // readynative:ui-jest-setup block in favour of a plain require("./src/components/ui/jest-setup").
  • F2 - tooling. scripts/ keeps doctor, gen-assets, gen-links, gen-theme, e2e (+ lib/{args,format,doctor,gen-assets,gen-links,gen-theme,e2e,readynative-state}.ts and the gen-theme-<gen id>.ts emitter of the active stack) and the tests of the kept scripts that do not need scripts/test-utils/fixture.ts. scripts/lib/gen-theme.ts keeps only the // readynative:gen <id> lines of the active stack, so its renderer map is a direct call. docs/, examples/, tools/ and .github/workflows/docs.yml are deleted, ci.yml is replaced by a typecheck/lint/test job, README.md / AGENTS.md are rendered from scripts/lib/templates/, and package.json loses gen/clean/docs/release/verify and @clack/prompts (setup stays as a one-line "already applied" echo so a second bun run setup exits 0). bun install runs after all of this, so bun.lock is refreshed.

Nothing outside scripts/lib/finalize.ts knows about the buyer's layout: the scripts that survive read .readynative.json through scripts/lib/readynative-state.ts, which imports no part of the module system (catalog / registry / schema / resolve).

Categories / options (first = default)

ui nativewind4·nativewind5·tamagui·unistyles·stylesheet | data react-query·apollo·swr·none | state zustand·jotai·none | storage kv-store·mmkv·async-storage | forms react-hook-form·none | i18n i18next·lingui·none | auth supabase·clerk·better-auth·none | payments revenuecat·adapty·stripe·none | analytics posthog·amplitude·none | crash sentry·none | push expo-notifications·none | backend api-routes·none | onboarding on·off | testing jest·none Presets: minimal (ui nativewind4 + storage kv-store, everything else none/off), default (nativewind4, react-query, zustand, kv-store, react-hook-form, i18next, onboarding on, testing jest, others none), saas (default + supabase, revenuecat, posthog, sentry, expo-notifications, api-routes). Phase 2 ships only the modules the default and minimal presets need (plus none/off modules for every category). Unknown options in a preset → setup error.

module.json

{
  "id": "react-query",
  "category": "data",
  "label": "TanStack Query",
  "hint": "server state + cache · Expo Go OK",
  "expoGo": true, // false → doctor requires a dev build
  "recommended": false, // true → docs flag it as "most people pick this"
  "dependencies": { "@tanstack/react-query": "^5.102.0" },
  "devDependencies": {},
  "expoInstall": [], // passed to `bunx expo install`
  "package": { "scripts": {}, "overrides": {}, "lint-staged": {} }, // shallow-merge into package.json (per top-level key)
  "app": { "expo": { "plugins": [] } }, // JSON merge patch onto app.config extras - see "app patch" below
  "tsconfig": { "include": [] }, // append-unique
  "files": ["src/lib/query.tsx"], // root-relative; must exist under files/
  "removeFiles": [], // root paths deleted when this module is selected
  "providers": [{ "import": "QueryProvider", "from": "@/lib/query", "order": 20 }],
  "env": [
    {
      "key": "EXPO_PUBLIC_API_URL",
      "example": "https://api.example.com",
      "required": false, // doctor-only: true → `bun doctor` fails when the key is unset (see "env")
      "docsUrl": "",
      "server": false, // true → server-only secret: no EXPO_PUBLIC_ prefix, never in src/lib/env.ts
    },
  ],
  "requires": { "storage": ["kv-store", "mmkv", "async-storage"] }, // category → allowed options
  "recommends": {},
  "conflicts": [], // conflicts: ["<category>/<option>"]
  "gen": [], // gen-theme targets, ui only: ["nativewind5"]
  "doctor": [{ "type": "env", "keys": ["EXPO_PUBLIC_API_URL"] }, { "type": "devBuild" }],
  "postSetup": ["..."],
  "privacy": {
    // what this module's SDK collects; composed by setup, see "privacy"
    "dataTypes": [
      {
        "type": "NSPrivacyCollectedDataTypeEmailAddress",
        "linked": true,
        "purposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"],
      },
    ],
    "accessedApis": [{ "type": "NSPrivacyAccessedAPICategoryUserDefaults", "reasons": ["CA92.1"] }],
    "tracking": false,
    "trackingDomains": [],
    "playDataSafety": {
      "sdk": "X (cat/opt)",
      "collects": ["..."],
      "sharedWith": "X (processor)",
      "disclosureUrl": "https://...",
    },
  },
}

env

  • required never changes the generated src/lib/env.ts (every key is z.string().optional()): a missing key must degrade the module ("Configure X"), not crash the app at import time. It only drives doctor: every required: true key is an implicit { "type": "env" } check, so a module lists a key in doctor only when it is optional-but-worth-checking (e.g. EXPO_PUBLIC_API_URL). Explicit + implicit keys are merged per module and deduped.
  • server: true (e.g. BETTER_AUTH_SECRET, STRIPE_SECRET_KEY): read via process.env.X in src/app/api/** / src/server/**; listed in .env.example under the trailing # server section; checked by doctor like any other key; never rendered into src/lib/env.ts, so it cannot leak into the bundle. Keys with server: true must not start with EXPO_PUBLIC_.
  • A key declared by several selected modules (typically EXPO_PUBLIC_API_URL) is deduped first-wins in copy order (ui first, then catalog order): the first module's example + docsUrl land in .env.example and in the doctor check; required is OR-ed.

Core dependencies

CORE_DEPENDENCIES in scripts/lib/catalog.ts = every root dependencies/devDependencies key that no default-preset module declares (expo, expo-constants, expo-device, react-native, zod, …). removeDependencies never touches them, so a module may redeclare a core package it imports (push/expo-notifications → expo-device, expo-constants) without turning it off for every selection that lacks the module. A unit test asserts the constant ⊆ root package.json; regenerate the list when core deps change.

none/off modules: { id, category, label, hint, expoGo: true, recommended: false, files: [], providers: [], env: [] } - everything else optional. Schema is zod in scripts/lib/schema.ts; every field except id/category/label has a default.

privacy

Declared once per module, composed by resolvePlan (composePrivacy): dataTypes dedupe by type (linked/tracking OR-ed, purposes unioned), accessedApis dedupe by category (reasons unioned), tracking OR-ed, domains unioned. The result is patched into app.expo.ios.privacyManifests (so app.config.ts ships an Apple privacy manifest without a static block) and the playDataSafety rows are stored in .readynative.json → modules.privacy.dataSafety, which bun run doctor --store prints as the Play Data safety draft - a finalized tree has no modules/ to read. Storage modules declare the UserDefaults required-reason API (they are the callers); service modules declare their data types and a Data safety row; none options declare nothing.

app patch

app.config.ts reads .readynative.json → modules.app (merged patch of every selected module's app.expo) and applies it: plugins append-unique by plugin id (string or [id, opts]), other keys deep-merged. So app.config.ts stays hand-written and modules never edit it.

Generated files (owned by setup, never by modules)

  • src/ui/index.ts:
    /**
     * THE swap point: `bun setup` rewrites this file to re-export the active stack.
     * Screens, components and routes import UI only from `@/ui`.
     */
    export * from "./contract";
    export * from "./theme-mode";
    
    export * from "./<ui option>";
  • src/providers.tsx: header comment (the 10..90 order table as today), then imports sorted by order then import name, one import { X } from "from"; line each (ThemeProvider as UiThemeProvider for the ui provider - the ui module declares { "import": "ThemeProvider", "as": "UiThemeProvider", "from": "@/ui", "order": 50 } - as is an optional field), and ToastHost from @/ui (always, order 90, rendered after children). Body: nested JSX from lowest order outward, {children} innermost, <ToastHost /> right after {children} inside the innermost provider. Prettier-formatted (run prettier on the output string via its API).
  • src/lib/env.ts: same structure as today; one z.string().optional() line per public (non-server) env key across selected modules (APP_VARIANT always first; required is doctor-only, see "env"), raw object listing optional(process.env.EXPO_PUBLIC_<KEY>) per key. Keys sorted, deduped.
  • .env.example: header, APP_VARIANT=dev, EXPO_PUBLIC_APP_VARIANT=dev, then per module a # <label> comment + KEY=example lines (+ # docs: url) for its public keys (a key shared by two modules appears once, first wins), then one # server (never EXPO_PUBLIC) section with the server: true keys grouped per module.
  • .readynative.json: { "version": 1, "preset": "default" | null, "selection": { "<category>": "<option>" }, "applied": true, "modules": { "app": {...merged app patch...}, "postSetup": [...] , "doctor": [...] }, "keepModules": true|false }.
  • package.json patches: add module dependencies/devDependencies (remove deps that belong ONLY to non-selected modules and are not in CORE_DEPENDENCIES), merge package block; sub-keys of object blocks (e.g. scripts.test) and tsconfig.include entries that belong ONLY to non-selected modules are removed the same way. Keys sorted like bun does. Top-level scalar package keys (e.g. main) are set by the selected module that declares them; when only non-selected modules declare one, the core default from PACKAGE_CORE_DEFAULTS (main: "expo-router/entry") is restored, or the key is deleted if it has no core default. JSON rewrites (package.json, tsconfig.json) are prettier-formatted and only happen when the parsed value changes.
  • Core tooling deps stay in the root package.json even when a module would also bring them (e.g. @types/node, needed by app.config.ts and scripts/**; with TS 6 the tsconfig lists "types": ["node"] explicitly). Engine unit tests (scripts/__tests__/**, scripts/test-utils/**) and jest.config.js are owned by testing/jest.

Ownership

Core (never removed): everything not listed in any module.json.files. A file listed by several modules is removed only if none of its owners is selected. A selected module's removeFiles beats every owner, selected or not - the path is not copied and is deleted. Copy order: ui first, then category order above.

Tests and mocks

  • Module-owned tests live in the module's files/ (e.g. modules/storage/mmkv/files/src/lib/__tests__/storage-mmkv.test.ts, modules/data/swr/files/src/hooks/__tests__/use-example-query.test.tsx). Two options of one category may own the same test path (src/stores/__tests__/example-store.test.ts is owned by both state/zustand and state/jotai); the selected one's copy wins.
  • Every shared test path must also be listed in testing/none.removeFiles (the per-stack src/ui/<stack>/jest-setup.ts included), so --testing none leaves no __tests__ behind regardless of which options own them - selected owners too, since removeFiles wins over files. When you add a test to a module, add its path there too (bunx jest scripts - registry.test.ts - checks files[] entries exist; the --testing none verify run is what catches a missing removeFiles entry).
  • Storage modules mock their native package with jest's __mocks__/ auto-mock convention: a root-level __mocks__/<package-name>.ts (modules/storage/mmkv/files/__mocks__/react-native-nitro-modules.ts, modules/storage/async-storage/files/__mocks__/@react-native-async-storage/async-storage.ts) is picked up automatically for that node module - no jest.mock() call in jest.setup.ts. The core jest.setup.ts mocks expo-sqlite/kv-store with { virtual: true } so it stays valid when expo-sqlite is not installed (storage=mmkv|async-storage). Core tests never import a storage package directly; they go through @/lib/storage (storage.getItem/setItem, async) so they pass on every adapter.

Scripts (bun run scripts/<x>.ts)

  • setup [--preset p] [--<category> <option>]… [--yes] [--dry-run] [--no-install] [--keep-modules] [--with-examples] [--root <dir>] - interactive via @clack/prompts when a TTY and not --yes. Idempotent: same selection → no diff, exit 0. When modules/ is gone (a previous run without --keep-modules) and .readynative.json.applied is set: same selection → "Already applied", exit 0; different selection → error pointing at --keep-modules.
  • gen-theme [--check] [--root <dir>] - tokens (+ brand) → one artefact set per ui module gen target, written under modules/ui/<stack>/files/ and, for the active ui (.readynative.json, default nativewind4), at the root: nativewind5 → src/global.css; nativewind4 → src/global.css + tailwind.config.js; tamagui → tamagui.config.ts. Output is prettier-formatted. --check exits 1 on drift in any target.
  • doctor [--root <dir>] - reads .readynative.json; checks env keys (doctor entries + every required: true key, .env then process env, with docsUrl), placeholders in readynative.config.ts (com.acme.app, empty urls/store ids), eas whoami (skip if eas-cli absent), Xcode (xcode-select -p) / Android SDK (ANDROID_HOME) when any selected module is expoGo:false, bun/node versions. Non-zero on failures, checklist output.
  • gen screen <name> | gen module <category> <option> - scaffolders.
  • clean [--no-examples] - the same strip setup runs: the examples tab (src/app/examples, src/screens/examples, src/examples.ts, the example hook/store and their tests) and its Settings row, _catalog and its Settings row, the weather demo (the Cities tab and its <NativeTabs.Trigger> in src/app/(tabs)/_layout.tsx, src/screens/weather, src/lib/weather, src/stores/weather.ts, src/hooks/use-forecast.ts), sample locales (keeps en, rewrites src/lib/i18n.ts), placeholder assets, tools/; the home screen becomes a plain welcome and the Weather tab turns back into Home. Every anchored edit is a no-op once applied and warns (never fails) when it cannot find its anchor. --no-examples keeps the weather demo and the examples tab. All scripts take --root so unit tests run them against a fixture tree in a temp dir. Logic lives in scripts/lib/*.ts; scripts/<x>.ts are thin CLIs.

Service modules (auth · payments · analytics · crash · push · backend)

  • Shim ownership. src/lib/{auth,analytics,crash,payments,push,location,widgets}.ts are owned by the <category>/none module and are one-line re-exports of src/lib/shims/<category>.ts (same pattern as i18n/none). A real module lists the same src/lib/<category>.ts in files[] and ships its own implementation of the shim's exported interface (Auth, Payments, Analytics, Crash, Push, Location, Widgets) with the same export names - core only ever imports @/lib/<category>. backend/none owns nothing (src/app/api/** and src/server/** are api-routes files).
  • src/hooks/use-auth-redirect.ts is owned by auth/none (no-op) and by every auth module. The root layout calls useOnboardingRedirect() then useAuthRedirect(); the contract (gate on useRootNavigationState().key, no-op while loading, unauthenticated outside (auth) → router.replace("/(auth)/sign-in"), authenticated inside (auth) → router.replace("/"), onboarding wins first) is documented in the hook itself. Auth modules ship the src/app/(auth)/ group (sign-in/up/reset).
  • Core hooks already wired: _layout.tsx calls analytics.screen(pathname) on every pathname change; the Settings screen renders an "Account" card with auth.signOut() only when auth.useSession().status === "authenticated".
  • Examples slugs reserved in src/examples.ts (the module owns src/app/examples/<slug>.tsx): auth (auth), paywall (payments), analytics (analytics), crash (crash), push (push), api (backend). Without the module the [slug] fallback renders "Module not installed".
  • module.json requirements for every service module: every env[] entry has a non-empty docsUrl; the keys the module cannot work without are required: true (doctor checks them implicitly; an explicit { "type": "env", "keys": [...] } entry is for optional keys still worth flagging, e.g. EXPO_PUBLIC_API_URL); server secrets are server: true; { "type": "devBuild" } when expoGo: false; postSetup names the dashboard steps (create the project, paste keys, register bundle ids / webhooks); docs.md contains a "Manual E2E checklist" section (sandbox account → real device flow), since CI only proves the module builds.

On this page

Get ReadyNative