Shipping seven iOS apps solo: the stack and the workflow
How I build seven indie iOS apps alone: one React Native stack, native Swift engines, Maestro flows, device checklists and a Next.js landing per app.

On this page
I make seven iOS apps on my own: Lockboxy (a private encrypted vault), Linkeeper (read-later), Minivid (video compressor), Ringsy (ringtone maker), Talkzy (teleprompter), Baton (baby tracker) and Stampzy (timestamp GPS camera). You can see them side by side on my app showcase.
Seven apps is only possible for one person if they stop being seven different projects. Every new app copies the shape of the one before it. Baton's agent contract says it plainly: "Built the way Subzy is built; when unsure, copy what Subzy does." Stampzy's says the same about Baton and Talkzy. This post is that shape: the stack, how each repo is laid out, and the loop a release goes through.

The shared stack
Every app is bare React Native CLI with TypeScript, not Expo. The five newest are on React Native 0.87 with the New Architecture and Hermes; Lockboxy and Linkeeper, the two oldest, are on 0.86. The rest of the JavaScript side is the same everywhere:
| Concern | What I use |
|---|---|
| State | Zustand, persisted to MMKV |
| Navigation | React Navigation 7 |
| Strings | i18next + react-i18next, ten catalogs |
| Purchases | RevenueCat (react-native-purchases) in all seven |
| Analytics | Google Analytics for Firebase in six; Baton has none, by rule |
| Ads | AdMob, only in Lockboxy and Linkeeper |
| Tests | Jest, plus swift test for the native parts |
The reason I stayed on the bare CLI is the second half of each app: a native Swift engine. The JavaScript never does the heavy work. Minivid has video-engine, image-engine and vault modules; Ringsy has ringtone-engine; Talkzy has prompter-engine; Stampzy has camera-engine; Baton has baton-core, a SQLite store that widgets and Live Activity buttons can write to while JS isn't running. Stampzy's first hard rule is "JavaScript never touches pixels": camera, overlay rendering, location and the clock are native, and JS sends a spec and gets events back at most once a second.
Each engine has an engine-contract.md. A method, prop or event changes in the contract doc, the TypeScript wrapper and the Swift bridge together, or not at all. Most engines also have a small Swift harness (tools/*-harness/run.sh) so I can test layout maths, audio cuts or the Vietnamese address lookup without building the whole app.
Vendor SDKs live behind ports
The rule I copy into every repo: no vendor SDK in feature code. RevenueCat sits behind a BillingPort, Firebase behind an AnalyticsPort, AdMob behind an AdsPort. Feature code calls track() with an event name from an allowlist in events.ts, and the allowlist says what may never be sent: Stampzy forbids an address, coordinates, a note or a photo code; Minivid forbids a file name, path or size.
That one rule pays off in three ways. Jest suites run against a fake billing port without a store. Swapping or removing an SDK is one adapter. And the privacy policy stays honest, because there is exactly one file to read to know what leaves the phone.
Monetization rules are also written down as tests, not as intentions. Stampzy's AGENTS.md has "the free-tier pledge is a test (entitlements.test.ts). Fix the change, not the test", and "the paywall shows a trial only when the store reports a free intro offer and RevenueCat says the user is eligible."

How each repo is laid out
Open any of the seven and you find the same skeleton:
AGENTS.md the contract: what the app is, hard rules, test table
CLAUDE.md a thin pointer to AGENTS.md
.claude/config.md stack, commands, ports, what is still to register
src/ features/, services/ (ports), store/, i18n
modules/<engine>/ the native Swift module
tools/ harnesses, i18n scripts, release/upload.sh
.maestro/ UI flows
docs/ PRODUCT.md, architecture/, features/, changelogs/,
app-store/, device-checklist.md, audits/AGENTS.md is written for any coding agent, and for me after a month away. It holds the hard rules (Baton: "No analytics or crash SDKs, ever"), a test table that says which layer is tested by which tool, and commit conventions (type(scope): subject). Every feature-shaped change appends to a changelog under docs/changelogs/, so "why is it like this" has an answer in the repo instead of in my head.
All seven apps are built on the same Mac, which needs its own rules. Each app has its own simulator, its own Metro port (Stampzy is on 8086, never the default 8081), DerivedData in its own temp folder, and every xcodebuild or pod install waits on one shared lock directory so two native builds never fight over CPU and disk.
Testing: Jest, Maestro, then a real iPhone
Testing has three layers, and every repo's test table names them:
- Jest for pure TypeScript: entitlements, timers, version maths, settings migrations, i18n parity, app boot.
- Maestro on the simulator for screens and flows. Counting subflows, there are about 170 Maestro YAML files across the seven repos. Debug-only launch arguments act as seams: Ringsy's
-RingsyUpdatePreview 9.9pretends the store has a newer version so a flow can screenshot the update card without the network. - A device checklist (
docs/device-checklist.md) for what the simulator cannot prove. Talkzy's opens with "Run on a real iPhone before a release; write the result and the device/iOS into the release's changelog entry", then lists items like Picture in Picture over TikTok Live for ten minutes, or voice-follow on Bluetooth mics.
yarn typecheck && yarn lint && yarn test
./tools/kit-harness/run.sh # swift test for the native core
maestro test --device <udid> .maestro/The Maestro flows also write the screenshots in docs/screenshots/, which is how I review a feature in Vietnamese and English without tapping through it. Stampzy's AGENTS.md ends with a section called "Honest reporting": separate what was run and verified from what was only written. The simulator has no camera, so "works" on the simulator is never the same claim as "works".
On top of that, each repo has a periodic audit. The October 2026 ones grade findings P0 to P3 and record the gates before and after. Baton's audit found that a failed SQLite write turned into an unhandled promise rejection and a Save button that seemed to do nothing, fixed it, and took the suite from 574 to 580 tests.
Ten languages from day one
Every app ships in English, Vietnamese, Japanese, Korean, Chinese, German, French, Spanish, Portuguese and Italian. Three things keep that manageable:
- A parity test. It fails on a missing file, a missing key, a lost
{{placeholder}}or, in Linkeeper's version, a plural form a language needs. - Native strings from the same source. Permission prompts and Siri phrases live in
docs/app-store/locales/*.json, andtools/i18n/apply_native.pywrites them into the.lprojfolders. - Listings in the repo. App Store name, subtitle and keywords per locale sit next to the code, and Stampzy has a script that checks them against App Store length limits.
Linkeeper also dropped its in-app language picker in 1.3.0: the system decides, using iOS's per-app language setting, and anything unknown falls back to English.
Release, landing and showcase
There is no EAS or CI service. The five newest apps have tools/release/upload.sh: it refuses to run while a billing test flag is on or the RevenueCat key is still a placeholder, checks for an Apple Distribution certificate, takes the build lock, then archives, exports and uploads to App Store Connect with xcodebuild. Lockboxy and Linkeeper are older and only have a Makefile for building and running; the script came later.
Each app has a landing page: Next.js 16 with the App Router, Tailwind 4, TypeScript, on Vercel, in Vietnamese and English. The dictionaries are typed so a missing translation is a build error. The landing rules are short and strict: the privacy policy must match what the app actually sends, there is no App Store link until the app is live (a "Coming soon" chip instead), and no fake reviews, ratings or user counts.
The showcase is one more Next.js site. A script pulls Apple's public iTunes lookup for every app and copies the real icons and App Store screenshots from the app repos, so it never drifts from what is on the store. The same lookup powers the in-app "new version" card, which I wrote up in an update prompt without a backend.
What I'd keep
- Copy the shape, not just the code. A new app that starts from a sibling's AGENTS.md, ports and folder layout is productive on day one.
- Push the hard parts into Swift with a written contract. It keeps JS simple and makes the native side testable on its own.
- Write the rules as tests. Free-tier promises, i18n parity and "the version constant matches Xcode" are tests, so they survive a rushed release.
- Keep the simulator honest. Maestro for flows, a device checklist for everything else, and a changelog entry that says which was which.
Related posts


