[{"data":1,"prerenderedAt":2105},["ShallowReactive",2],{"category-data-indie":3},[4,1182],{"_path":5,"_dir":6,"_draft":7,"_partial":7,"_locale":8,"title":9,"description":10,"date":11,"image":12,"alt":13,"ogImage":12,"tags":14,"published":21,"body":22,"_type":1175,"_id":1176,"_source":1177,"_file":1178,"_stem":1179,"_extension":1180,"sitemap":1181},"/blogs/swiftui-ai-narrative-apps-april-2026","blogs",false,"","Building Narrative AI Apps in SwiftUI - Lessons from Six Months in Production","Patterns, pitfalls, and the SwiftUI architecture I wish I had known before shipping an AI-driven narrative app to the App Store.","22nd Apr 2026","/blogs-img/blog5.jpg","SwiftUI and AI narrative apps",[15,16,17,18,19,20],"swift","swiftui","ai","ios","indie","architecture",true,{"type":23,"children":24,"toc":1164},"root",[25,34,40,45,51,56,81,94,100,113,233,238,281,294,371,384,390,402,455,467,473,478,483,582,587,593,620,625,678,690,734,762,768,780,898,903,926,939,945,963,1016,1036,1049,1079,1092,1098,1148,1153,1158],{"type":26,"tag":27,"props":28,"children":30},"element","h3",{"id":29},"six-months-in",[31],{"type":32,"value":33},"text","Six months in",{"type":26,"tag":35,"props":36,"children":37},"p",{},[38],{"type":32,"value":39},"Elsewhere has been live on the App Store for about six months as I write this. It's a small narrative app — an AI life simulator where every choice splits the universe — and the build-to-ship cycle taught me more about SwiftUI than the previous three years combined.",{"type":26,"tag":35,"props":41,"children":42},{},[43],{"type":32,"value":44},"This is the post I wish someone had handed me before I started. Not a tutorial. A field guide.",{"type":26,"tag":27,"props":46,"children":48},{"id":47},"the-fundamental-tension",[49],{"type":32,"value":50},"The fundamental tension",{"type":26,"tag":35,"props":52,"children":53},{},[54],{"type":32,"value":55},"Narrative AI apps have a structural problem that traditional apps don't:",{"type":26,"tag":57,"props":58,"children":59},"ul",{},[60,66,71,76],{"type":26,"tag":61,"props":62,"children":63},"li",{},[64],{"type":32,"value":65},"The screen waits on a model.",{"type":26,"tag":61,"props":67,"children":68},{},[69],{"type":32,"value":70},"The model is slow (300ms on-device, 2–4 seconds in the cloud).",{"type":26,"tag":61,"props":72,"children":73},{},[74],{"type":32,"value":75},"The model can fail.",{"type":26,"tag":61,"props":77,"children":78},{},[79],{"type":32,"value":80},"The user shouldn't feel any of this.",{"type":26,"tag":35,"props":82,"children":83},{},[84,86,92],{"type":32,"value":85},"Most SwiftUI architectures I see online assume your data either lives locally (instant) or comes from a REST API (predictable). LLM responses are neither. They're streaming, lossy, variable-length, and emotionally charged — the user is ",{"type":26,"tag":87,"props":88,"children":89},"em",{},[90],{"type":32,"value":91},"waiting to see who they became in the other universe",{"type":32,"value":93},". A spinner is not enough.",{"type":26,"tag":27,"props":95,"children":97},{"id":96},"the-pattern-that-finally-worked",[98],{"type":32,"value":99},"The pattern that finally worked",{"type":26,"tag":35,"props":101,"children":102},{},[103,105,111],{"type":32,"value":104},"After three rewrites, here's what stuck. I call it the ",{"type":26,"tag":106,"props":107,"children":108},"strong",{},[109],{"type":32,"value":110},"Anticipation–Stream–Commit",{"type":32,"value":112}," loop.",{"type":26,"tag":114,"props":115,"children":118},"pre",{"className":116,"code":117,"language":15,"meta":8,"style":8},"language-swift shiki shiki-themes dracula","@Observable\nfinal class EventState {\n    enum Phase {\n        case idle\n        case anticipating(String)\n        case streaming(partial: String)\n        case ready(Event)\n        case failed(Error)\n    }\n\n    var phase: Phase = .idle\n}\n",[119],{"type":26,"tag":120,"props":121,"children":122},"code",{"__ignoreMap":8},[123,134,143,152,161,170,179,188,197,206,215,224],{"type":26,"tag":124,"props":125,"children":128},"span",{"class":126,"line":127},"line",1,[129],{"type":26,"tag":124,"props":130,"children":131},{},[132],{"type":32,"value":133},"@Observable\n",{"type":26,"tag":124,"props":135,"children":137},{"class":126,"line":136},2,[138],{"type":26,"tag":124,"props":139,"children":140},{},[141],{"type":32,"value":142},"final class EventState {\n",{"type":26,"tag":124,"props":144,"children":146},{"class":126,"line":145},3,[147],{"type":26,"tag":124,"props":148,"children":149},{},[150],{"type":32,"value":151},"    enum Phase {\n",{"type":26,"tag":124,"props":153,"children":155},{"class":126,"line":154},4,[156],{"type":26,"tag":124,"props":157,"children":158},{},[159],{"type":32,"value":160},"        case idle\n",{"type":26,"tag":124,"props":162,"children":164},{"class":126,"line":163},5,[165],{"type":26,"tag":124,"props":166,"children":167},{},[168],{"type":32,"value":169},"        case anticipating(String)\n",{"type":26,"tag":124,"props":171,"children":173},{"class":126,"line":172},6,[174],{"type":26,"tag":124,"props":175,"children":176},{},[177],{"type":32,"value":178},"        case streaming(partial: String)\n",{"type":26,"tag":124,"props":180,"children":182},{"class":126,"line":181},7,[183],{"type":26,"tag":124,"props":184,"children":185},{},[186],{"type":32,"value":187},"        case ready(Event)\n",{"type":26,"tag":124,"props":189,"children":191},{"class":126,"line":190},8,[192],{"type":26,"tag":124,"props":193,"children":194},{},[195],{"type":32,"value":196},"        case failed(Error)\n",{"type":26,"tag":124,"props":198,"children":200},{"class":126,"line":199},9,[201],{"type":26,"tag":124,"props":202,"children":203},{},[204],{"type":32,"value":205},"    }\n",{"type":26,"tag":124,"props":207,"children":209},{"class":126,"line":208},10,[210],{"type":26,"tag":124,"props":211,"children":212},{"emptyLinePlaceholder":21},[213],{"type":32,"value":214},"\n",{"type":26,"tag":124,"props":216,"children":218},{"class":126,"line":217},11,[219],{"type":26,"tag":124,"props":220,"children":221},{},[222],{"type":32,"value":223},"    var phase: Phase = .idle\n",{"type":26,"tag":124,"props":225,"children":227},{"class":126,"line":226},12,[228],{"type":26,"tag":124,"props":229,"children":230},{},[231],{"type":32,"value":232},"}\n",{"type":26,"tag":35,"props":234,"children":235},{},[236],{"type":32,"value":237},"Each phase maps to a distinct SwiftUI view treatment:",{"type":26,"tag":57,"props":239,"children":240},{},[241,251,261,271],{"type":26,"tag":61,"props":242,"children":243},{},[244,249],{"type":26,"tag":106,"props":245,"children":246},{},[247],{"type":32,"value":248},"Anticipating:",{"type":32,"value":250}," the universe is \"thinking\" — a soft pulse animation, a quote from a previous moment, no spinner",{"type":26,"tag":61,"props":252,"children":253},{},[254,259],{"type":26,"tag":106,"props":255,"children":256},{},[257],{"type":32,"value":258},"Streaming:",{"type":32,"value":260}," tokens land on screen one at a time, slower than the model can produce them (this is intentional — see below)",{"type":26,"tag":61,"props":262,"children":263},{},[264,269],{"type":26,"tag":106,"props":265,"children":266},{},[267],{"type":32,"value":268},"Ready:",{"type":32,"value":270}," the event card is solid, tappable, real",{"type":26,"tag":61,"props":272,"children":273},{},[274,279],{"type":26,"tag":106,"props":275,"children":276},{},[277],{"type":32,"value":278},"Failed:",{"type":32,"value":280}," gentle, never an alert — \"the universe got quiet, try again\"",{"type":26,"tag":35,"props":282,"children":283},{},[284,286,292],{"type":32,"value":285},"The view becomes a state machine in ",{"type":26,"tag":120,"props":287,"children":289},{"className":288},[],[290],{"type":32,"value":291},"body",{"type":32,"value":293},":",{"type":26,"tag":114,"props":295,"children":297},{"className":116,"code":296,"language":15,"meta":8,"style":8},"var body: some View {\n    switch state.phase {\n    case .idle:              EmptyView()\n    case .anticipating(let q): AnticipationView(quote: q)\n    case .streaming(let p):  StreamingView(text: p)\n    case .ready(let event):  EventCard(event: event)\n    case .failed:            QuietRecoveryView()\n    }\n}\n",[298],{"type":26,"tag":120,"props":299,"children":300},{"__ignoreMap":8},[301,309,317,325,333,341,349,357,364],{"type":26,"tag":124,"props":302,"children":303},{"class":126,"line":127},[304],{"type":26,"tag":124,"props":305,"children":306},{},[307],{"type":32,"value":308},"var body: some View {\n",{"type":26,"tag":124,"props":310,"children":311},{"class":126,"line":136},[312],{"type":26,"tag":124,"props":313,"children":314},{},[315],{"type":32,"value":316},"    switch state.phase {\n",{"type":26,"tag":124,"props":318,"children":319},{"class":126,"line":145},[320],{"type":26,"tag":124,"props":321,"children":322},{},[323],{"type":32,"value":324},"    case .idle:              EmptyView()\n",{"type":26,"tag":124,"props":326,"children":327},{"class":126,"line":154},[328],{"type":26,"tag":124,"props":329,"children":330},{},[331],{"type":32,"value":332},"    case .anticipating(let q): AnticipationView(quote: q)\n",{"type":26,"tag":124,"props":334,"children":335},{"class":126,"line":163},[336],{"type":26,"tag":124,"props":337,"children":338},{},[339],{"type":32,"value":340},"    case .streaming(let p):  StreamingView(text: p)\n",{"type":26,"tag":124,"props":342,"children":343},{"class":126,"line":172},[344],{"type":26,"tag":124,"props":345,"children":346},{},[347],{"type":32,"value":348},"    case .ready(let event):  EventCard(event: event)\n",{"type":26,"tag":124,"props":350,"children":351},{"class":126,"line":181},[352],{"type":26,"tag":124,"props":353,"children":354},{},[355],{"type":32,"value":356},"    case .failed:            QuietRecoveryView()\n",{"type":26,"tag":124,"props":358,"children":359},{"class":126,"line":190},[360],{"type":26,"tag":124,"props":361,"children":362},{},[363],{"type":32,"value":205},{"type":26,"tag":124,"props":365,"children":366},{"class":126,"line":199},[367],{"type":26,"tag":124,"props":368,"children":369},{},[370],{"type":32,"value":232},{"type":26,"tag":35,"props":372,"children":373},{},[374,376,382],{"type":32,"value":375},"What I love about this: SwiftUI's diffing handles the transitions. No ",{"type":26,"tag":120,"props":377,"children":379},{"className":378},[],[380],{"type":32,"value":381},"withAnimation",{"type":32,"value":383}," calls scattered through the codebase. No \"did the spinner show?\" race conditions.",{"type":26,"tag":27,"props":385,"children":387},{"id":386},"slow-down-the-stream",[388],{"type":32,"value":389},"Slow down the stream",{"type":26,"tag":35,"props":391,"children":392},{},[393,395,400],{"type":32,"value":394},"This is counterintuitive: I deliberately throttle the model's output before showing it to the user. The on-device model produces a 60-word event in about 400ms. That's ",{"type":26,"tag":87,"props":396,"children":397},{},[398],{"type":32,"value":399},"too fast",{"type":32,"value":401},". The user doesn't have time to feel the moment.",{"type":26,"tag":114,"props":403,"children":405},{"className":116,"code":404,"language":15,"meta":8,"style":8},"func reveal(_ text: String) async {\n    for token in tokens(in: text) {\n        state.phase = .streaming(partial: currentText + token)\n        try? await Task.sleep(for: .milliseconds(30))\n    }\n}\n",[406],{"type":26,"tag":120,"props":407,"children":408},{"__ignoreMap":8},[409,417,425,433,441,448],{"type":26,"tag":124,"props":410,"children":411},{"class":126,"line":127},[412],{"type":26,"tag":124,"props":413,"children":414},{},[415],{"type":32,"value":416},"func reveal(_ text: String) async {\n",{"type":26,"tag":124,"props":418,"children":419},{"class":126,"line":136},[420],{"type":26,"tag":124,"props":421,"children":422},{},[423],{"type":32,"value":424},"    for token in tokens(in: text) {\n",{"type":26,"tag":124,"props":426,"children":427},{"class":126,"line":145},[428],{"type":26,"tag":124,"props":429,"children":430},{},[431],{"type":32,"value":432},"        state.phase = .streaming(partial: currentText + token)\n",{"type":26,"tag":124,"props":434,"children":435},{"class":126,"line":154},[436],{"type":26,"tag":124,"props":437,"children":438},{},[439],{"type":32,"value":440},"        try? await Task.sleep(for: .milliseconds(30))\n",{"type":26,"tag":124,"props":442,"children":443},{"class":126,"line":163},[444],{"type":26,"tag":124,"props":445,"children":446},{},[447],{"type":32,"value":205},{"type":26,"tag":124,"props":449,"children":450},{"class":126,"line":172},[451],{"type":26,"tag":124,"props":452,"children":453},{},[454],{"type":32,"value":232},{"type":26,"tag":35,"props":456,"children":457},{},[458,460,465],{"type":32,"value":459},"30ms per token feels like writing. 5ms per token feels like a printer. The model is fast — the ",{"type":26,"tag":87,"props":461,"children":462},{},[463],{"type":32,"value":464},"reveal",{"type":32,"value":466}," is the design choice. Apps that show text instantly miss the whole point of narrative.",{"type":26,"tag":27,"props":468,"children":470},{"id":469},"caching-across-universes",[471],{"type":32,"value":472},"Caching across universes",{"type":26,"tag":35,"props":474,"children":475},{},[476],{"type":32,"value":477},"In Elsewhere, every choice spawns a new universe. The naïve approach is to ask the model \"given this biography, generate an event\" every single time. That's expensive (cloud), slow (on-device with cold cache), and produces uneven results.",{"type":26,"tag":35,"props":479,"children":480},{},[481],{"type":32,"value":482},"What I do instead: a tiered cache keyed by biography hash and recent-choice fingerprint.",{"type":26,"tag":114,"props":484,"children":486},{"className":116,"code":485,"language":15,"meta":8,"style":8},"struct EventCache {\n    let store: Cache\u003CEventKey, Event> = .init(limit: 200)\n\n    func event(for context: EventContext) async throws -> Event {\n        let key = EventKey(from: context)\n        if let hit = store[key] { return hit }\n\n        let event = try await ai.generate(context)\n        store[key] = event\n        return event\n    }\n}\n",[487],{"type":26,"tag":120,"props":488,"children":489},{"__ignoreMap":8},[490,498,506,513,521,529,537,544,552,560,568,575],{"type":26,"tag":124,"props":491,"children":492},{"class":126,"line":127},[493],{"type":26,"tag":124,"props":494,"children":495},{},[496],{"type":32,"value":497},"struct EventCache {\n",{"type":26,"tag":124,"props":499,"children":500},{"class":126,"line":136},[501],{"type":26,"tag":124,"props":502,"children":503},{},[504],{"type":32,"value":505},"    let store: Cache\u003CEventKey, Event> = .init(limit: 200)\n",{"type":26,"tag":124,"props":507,"children":508},{"class":126,"line":145},[509],{"type":26,"tag":124,"props":510,"children":511},{"emptyLinePlaceholder":21},[512],{"type":32,"value":214},{"type":26,"tag":124,"props":514,"children":515},{"class":126,"line":154},[516],{"type":26,"tag":124,"props":517,"children":518},{},[519],{"type":32,"value":520},"    func event(for context: EventContext) async throws -> Event {\n",{"type":26,"tag":124,"props":522,"children":523},{"class":126,"line":163},[524],{"type":26,"tag":124,"props":525,"children":526},{},[527],{"type":32,"value":528},"        let key = EventKey(from: context)\n",{"type":26,"tag":124,"props":530,"children":531},{"class":126,"line":172},[532],{"type":26,"tag":124,"props":533,"children":534},{},[535],{"type":32,"value":536},"        if let hit = store[key] { return hit }\n",{"type":26,"tag":124,"props":538,"children":539},{"class":126,"line":181},[540],{"type":26,"tag":124,"props":541,"children":542},{"emptyLinePlaceholder":21},[543],{"type":32,"value":214},{"type":26,"tag":124,"props":545,"children":546},{"class":126,"line":190},[547],{"type":26,"tag":124,"props":548,"children":549},{},[550],{"type":32,"value":551},"        let event = try await ai.generate(context)\n",{"type":26,"tag":124,"props":553,"children":554},{"class":126,"line":199},[555],{"type":26,"tag":124,"props":556,"children":557},{},[558],{"type":32,"value":559},"        store[key] = event\n",{"type":26,"tag":124,"props":561,"children":562},{"class":126,"line":208},[563],{"type":26,"tag":124,"props":564,"children":565},{},[566],{"type":32,"value":567},"        return event\n",{"type":26,"tag":124,"props":569,"children":570},{"class":126,"line":217},[571],{"type":26,"tag":124,"props":572,"children":573},{},[574],{"type":32,"value":205},{"type":26,"tag":124,"props":576,"children":577},{"class":126,"line":226},[578],{"type":26,"tag":124,"props":579,"children":580},{},[581],{"type":32,"value":232},{"type":26,"tag":35,"props":583,"children":584},{},[585],{"type":32,"value":586},"About 18% of generation calls hit cache. That's 18% of latency the user doesn't feel, and 18% of API spend I don't pay. The win compounds as a user's universe map grows — recurring patterns become local.",{"type":26,"tag":27,"props":588,"children":590},{"id":589},"what-observable-changed-for-me",[591],{"type":32,"value":592},"What @Observable changed for me",{"type":26,"tag":35,"props":594,"children":595},{},[596,602,604,610,612,618],{"type":26,"tag":120,"props":597,"children":599},{"className":598},[],[600],{"type":32,"value":601},"@Observable",{"type":32,"value":603}," (the macro-based observation system) is the single biggest SwiftUI improvement of the past two years. I no longer write ",{"type":26,"tag":120,"props":605,"children":607},{"className":606},[],[608],{"type":32,"value":609},"@Published",{"type":32,"value":611}," on a thousand properties. I no longer fight ",{"type":26,"tag":120,"props":613,"children":615},{"className":614},[],[616],{"type":32,"value":617},"ObservableObject",{"type":32,"value":619}," re-render storms. The view only re-renders when the property it reads actually changes.",{"type":26,"tag":35,"props":621,"children":622},{},[623],{"type":32,"value":624},"The pattern:",{"type":26,"tag":114,"props":626,"children":628},{"className":116,"code":627,"language":15,"meta":8,"style":8},"@Observable\nfinal class UniverseStore {\n    var current: Universe?\n    var map: UniverseMap = .empty\n    var credits: Int = 5\n}\n",[629],{"type":26,"tag":120,"props":630,"children":631},{"__ignoreMap":8},[632,639,647,655,663,671],{"type":26,"tag":124,"props":633,"children":634},{"class":126,"line":127},[635],{"type":26,"tag":124,"props":636,"children":637},{},[638],{"type":32,"value":133},{"type":26,"tag":124,"props":640,"children":641},{"class":126,"line":136},[642],{"type":26,"tag":124,"props":643,"children":644},{},[645],{"type":32,"value":646},"final class UniverseStore {\n",{"type":26,"tag":124,"props":648,"children":649},{"class":126,"line":145},[650],{"type":26,"tag":124,"props":651,"children":652},{},[653],{"type":32,"value":654},"    var current: Universe?\n",{"type":26,"tag":124,"props":656,"children":657},{"class":126,"line":154},[658],{"type":26,"tag":124,"props":659,"children":660},{},[661],{"type":32,"value":662},"    var map: UniverseMap = .empty\n",{"type":26,"tag":124,"props":664,"children":665},{"class":126,"line":163},[666],{"type":26,"tag":124,"props":667,"children":668},{},[669],{"type":32,"value":670},"    var credits: Int = 5\n",{"type":26,"tag":124,"props":672,"children":673},{"class":126,"line":172},[674],{"type":26,"tag":124,"props":675,"children":676},{},[677],{"type":32,"value":232},{"type":26,"tag":35,"props":679,"children":680},{},[681,683,689],{"type":32,"value":682},"In a view that reads only ",{"type":26,"tag":120,"props":684,"children":686},{"className":685},[],[687],{"type":32,"value":688},"credits",{"type":32,"value":293},{"type":26,"tag":114,"props":691,"children":693},{"className":116,"code":692,"language":15,"meta":8,"style":8},"@Environment(UniverseStore.self) private var store\n\nvar body: some View {\n    Text(\"\\(store.credits) splits left\")\n}\n",[694],{"type":26,"tag":120,"props":695,"children":696},{"__ignoreMap":8},[697,705,712,719,727],{"type":26,"tag":124,"props":698,"children":699},{"class":126,"line":127},[700],{"type":26,"tag":124,"props":701,"children":702},{},[703],{"type":32,"value":704},"@Environment(UniverseStore.self) private var store\n",{"type":26,"tag":124,"props":706,"children":707},{"class":126,"line":136},[708],{"type":26,"tag":124,"props":709,"children":710},{"emptyLinePlaceholder":21},[711],{"type":32,"value":214},{"type":26,"tag":124,"props":713,"children":714},{"class":126,"line":145},[715],{"type":26,"tag":124,"props":716,"children":717},{},[718],{"type":32,"value":308},{"type":26,"tag":124,"props":720,"children":721},{"class":126,"line":154},[722],{"type":26,"tag":124,"props":723,"children":724},{},[725],{"type":32,"value":726},"    Text(\"\\(store.credits) splits left\")\n",{"type":26,"tag":124,"props":728,"children":729},{"class":126,"line":163},[730],{"type":26,"tag":124,"props":731,"children":732},{},[733],{"type":32,"value":232},{"type":26,"tag":35,"props":735,"children":736},{},[737,739,745,747,752,754,760],{"type":32,"value":738},"When ",{"type":26,"tag":120,"props":740,"children":742},{"className":741},[],[743],{"type":32,"value":744},"current",{"type":32,"value":746}," changes but ",{"type":26,"tag":120,"props":748,"children":750},{"className":749},[],[751],{"type":32,"value":688},{"type":32,"value":753}," doesn't, this view doesn't re-render. With the old ",{"type":26,"tag":120,"props":755,"children":757},{"className":756},[],[758],{"type":32,"value":759},"@ObservableObject",{"type":32,"value":761},", it would have. Six months in, I cannot overstate how much smoother my app feels for this one change.",{"type":26,"tag":27,"props":763,"children":765},{"id":764},"state-persistence-isar-to-swiftdata",[766],{"type":32,"value":767},"State persistence: Isar to SwiftData",{"type":26,"tag":35,"props":769,"children":770},{},[771,773,778],{"type":32,"value":772},"I started Elsewhere on Isar — Flutter heritage, fast, familiar. I migrated to SwiftData around month three. Honestly: SwiftData was painful in 2024 and is fine in 2026. Migrations still scare me but the day-to-day API is good, the integration with ",{"type":26,"tag":120,"props":774,"children":776},{"className":775},[],[777],{"type":32,"value":601},{"type":32,"value":779}," is seamless, and the model definitions are pure Swift.",{"type":26,"tag":114,"props":781,"children":783},{"className":116,"code":782,"language":15,"meta":8,"style":8},"@Model\nfinal class Universe {\n    var id: UUID\n    var biography: String\n    var choices: [Choice]\n    var createdAt: Date\n\n    init(biography: String) {\n        self.id = UUID()\n        self.biography = biography\n        self.choices = []\n        self.createdAt = .now\n    }\n}\n",[784],{"type":26,"tag":120,"props":785,"children":786},{"__ignoreMap":8},[787,795,803,811,819,827,835,842,850,858,866,874,882,890],{"type":26,"tag":124,"props":788,"children":789},{"class":126,"line":127},[790],{"type":26,"tag":124,"props":791,"children":792},{},[793],{"type":32,"value":794},"@Model\n",{"type":26,"tag":124,"props":796,"children":797},{"class":126,"line":136},[798],{"type":26,"tag":124,"props":799,"children":800},{},[801],{"type":32,"value":802},"final class Universe {\n",{"type":26,"tag":124,"props":804,"children":805},{"class":126,"line":145},[806],{"type":26,"tag":124,"props":807,"children":808},{},[809],{"type":32,"value":810},"    var id: UUID\n",{"type":26,"tag":124,"props":812,"children":813},{"class":126,"line":154},[814],{"type":26,"tag":124,"props":815,"children":816},{},[817],{"type":32,"value":818},"    var biography: String\n",{"type":26,"tag":124,"props":820,"children":821},{"class":126,"line":163},[822],{"type":26,"tag":124,"props":823,"children":824},{},[825],{"type":32,"value":826},"    var choices: [Choice]\n",{"type":26,"tag":124,"props":828,"children":829},{"class":126,"line":172},[830],{"type":26,"tag":124,"props":831,"children":832},{},[833],{"type":32,"value":834},"    var createdAt: Date\n",{"type":26,"tag":124,"props":836,"children":837},{"class":126,"line":181},[838],{"type":26,"tag":124,"props":839,"children":840},{"emptyLinePlaceholder":21},[841],{"type":32,"value":214},{"type":26,"tag":124,"props":843,"children":844},{"class":126,"line":190},[845],{"type":26,"tag":124,"props":846,"children":847},{},[848],{"type":32,"value":849},"    init(biography: String) {\n",{"type":26,"tag":124,"props":851,"children":852},{"class":126,"line":199},[853],{"type":26,"tag":124,"props":854,"children":855},{},[856],{"type":32,"value":857},"        self.id = UUID()\n",{"type":26,"tag":124,"props":859,"children":860},{"class":126,"line":208},[861],{"type":26,"tag":124,"props":862,"children":863},{},[864],{"type":32,"value":865},"        self.biography = biography\n",{"type":26,"tag":124,"props":867,"children":868},{"class":126,"line":217},[869],{"type":26,"tag":124,"props":870,"children":871},{},[872],{"type":32,"value":873},"        self.choices = []\n",{"type":26,"tag":124,"props":875,"children":876},{"class":126,"line":226},[877],{"type":26,"tag":124,"props":878,"children":879},{},[880],{"type":32,"value":881},"        self.createdAt = .now\n",{"type":26,"tag":124,"props":883,"children":885},{"class":126,"line":884},13,[886],{"type":26,"tag":124,"props":887,"children":888},{},[889],{"type":32,"value":205},{"type":26,"tag":124,"props":891,"children":893},{"class":126,"line":892},14,[894],{"type":26,"tag":124,"props":895,"children":896},{},[897],{"type":32,"value":232},{"type":26,"tag":35,"props":899,"children":900},{},[901],{"type":32,"value":902},"Fetch from a view:",{"type":26,"tag":114,"props":904,"children":906},{"className":116,"code":905,"language":15,"meta":8,"style":8},"@Query(sort: \\Universe.createdAt, order: .reverse)\nprivate var universes: [Universe]\n",[907],{"type":26,"tag":120,"props":908,"children":909},{"__ignoreMap":8},[910,918],{"type":26,"tag":124,"props":911,"children":912},{"class":126,"line":127},[913],{"type":26,"tag":124,"props":914,"children":915},{},[916],{"type":32,"value":917},"@Query(sort: \\Universe.createdAt, order: .reverse)\n",{"type":26,"tag":124,"props":919,"children":920},{"class":126,"line":136},[921],{"type":26,"tag":124,"props":922,"children":923},{},[924],{"type":32,"value":925},"private var universes: [Universe]\n",{"type":26,"tag":35,"props":927,"children":928},{},[929,931,937],{"type":32,"value":930},"It feels like SwiftUI's ",{"type":26,"tag":120,"props":932,"children":934},{"className":933},[],[935],{"type":32,"value":936},"@State",{"type":32,"value":938}," for persistent data. Which is what we wanted all along.",{"type":26,"tag":27,"props":940,"children":942},{"id":941},"the-bug-id-warn-you-about",[943],{"type":32,"value":944},"The bug I'd warn you about",{"type":26,"tag":35,"props":946,"children":947},{},[948,950,955,957,961],{"type":32,"value":949},"One subtle thing: SwiftUI re-evaluates ",{"type":26,"tag":120,"props":951,"children":953},{"className":952},[],[954],{"type":32,"value":291},{"type":32,"value":956}," when state changes, but the ",{"type":26,"tag":87,"props":958,"children":959},{},[960],{"type":32,"value":291},{"type":32,"value":962}," itself shouldn't kick off async work. The first version of Elsewhere had this:",{"type":26,"tag":114,"props":964,"children":966},{"className":116,"code":965,"language":15,"meta":8,"style":8},"var body: some View {\n    EventCard(event: event)\n        .onAppear {\n            Task { await state.generateNext() }  // ❌\n        }\n}\n",[967],{"type":26,"tag":120,"props":968,"children":969},{"__ignoreMap":8},[970,977,985,993,1001,1009],{"type":26,"tag":124,"props":971,"children":972},{"class":126,"line":127},[973],{"type":26,"tag":124,"props":974,"children":975},{},[976],{"type":32,"value":308},{"type":26,"tag":124,"props":978,"children":979},{"class":126,"line":136},[980],{"type":26,"tag":124,"props":981,"children":982},{},[983],{"type":32,"value":984},"    EventCard(event: event)\n",{"type":26,"tag":124,"props":986,"children":987},{"class":126,"line":145},[988],{"type":26,"tag":124,"props":989,"children":990},{},[991],{"type":32,"value":992},"        .onAppear {\n",{"type":26,"tag":124,"props":994,"children":995},{"class":126,"line":154},[996],{"type":26,"tag":124,"props":997,"children":998},{},[999],{"type":32,"value":1000},"            Task { await state.generateNext() }  // ❌\n",{"type":26,"tag":124,"props":1002,"children":1003},{"class":126,"line":163},[1004],{"type":26,"tag":124,"props":1005,"children":1006},{},[1007],{"type":32,"value":1008},"        }\n",{"type":26,"tag":124,"props":1010,"children":1011},{"class":126,"line":172},[1012],{"type":26,"tag":124,"props":1013,"children":1014},{},[1015],{"type":32,"value":232},{"type":26,"tag":35,"props":1017,"children":1018},{},[1019,1021,1027,1029,1034],{"type":32,"value":1020},"That ",{"type":26,"tag":120,"props":1022,"children":1024},{"className":1023},[],[1025],{"type":32,"value":1026},"onAppear",{"type":32,"value":1028}," fires every time the view re-enters the hierarchy. Which in a map-of-universes app, is ",{"type":26,"tag":87,"props":1030,"children":1031},{},[1032],{"type":32,"value":1033},"constantly",{"type":32,"value":1035},". I burned hundreds of dollars in unnecessary Gemini calls before catching it.",{"type":26,"tag":35,"props":1037,"children":1038},{},[1039,1041,1047],{"type":32,"value":1040},"Use ",{"type":26,"tag":120,"props":1042,"children":1044},{"className":1043},[],[1045],{"type":32,"value":1046},".task(id:)",{"type":32,"value":1048}," instead:",{"type":26,"tag":114,"props":1050,"children":1052},{"className":116,"code":1051,"language":15,"meta":8,"style":8},".task(id: universe.id) {\n    await state.generateNext()\n}\n",[1053],{"type":26,"tag":120,"props":1054,"children":1055},{"__ignoreMap":8},[1056,1064,1072],{"type":26,"tag":124,"props":1057,"children":1058},{"class":126,"line":127},[1059],{"type":26,"tag":124,"props":1060,"children":1061},{},[1062],{"type":32,"value":1063},".task(id: universe.id) {\n",{"type":26,"tag":124,"props":1065,"children":1066},{"class":126,"line":136},[1067],{"type":26,"tag":124,"props":1068,"children":1069},{},[1070],{"type":32,"value":1071},"    await state.generateNext()\n",{"type":26,"tag":124,"props":1073,"children":1074},{"class":126,"line":145},[1075],{"type":26,"tag":124,"props":1076,"children":1077},{},[1078],{"type":32,"value":232},{"type":26,"tag":35,"props":1080,"children":1081},{},[1082,1084,1090],{"type":32,"value":1083},"This runs once per ",{"type":26,"tag":120,"props":1085,"children":1087},{"className":1086},[],[1088],{"type":32,"value":1089},"universe.id",{"type":32,"value":1091},", cancels cleanly when the view goes away, and never double-fires. Use it for anything async tied to view appearance.",{"type":26,"tag":27,"props":1093,"children":1095},{"id":1094},"what-id-tell-past-me",[1096],{"type":32,"value":1097},"What I'd tell past-me",{"type":26,"tag":1099,"props":1100,"children":1101},"ol",{},[1102,1107,1112,1117,1143],{"type":26,"tag":61,"props":1103,"children":1104},{},[1105],{"type":32,"value":1106},"Pick on-device first, cloud as fallback. Cost compounds invisibly.",{"type":26,"tag":61,"props":1108,"children":1109},{},[1110],{"type":32,"value":1111},"Treat AI latency as a design problem, not an engineering problem.",{"type":26,"tag":61,"props":1113,"children":1114},{},[1115],{"type":32,"value":1116},"Don't let the LLM dictate your view architecture — wrap it in state machines.",{"type":26,"tag":61,"props":1118,"children":1119},{},[1120,1122,1127,1129,1134,1136,1141],{"type":32,"value":1121},"Trust ",{"type":26,"tag":120,"props":1123,"children":1125},{"className":1124},[],[1126],{"type":32,"value":601},{"type":32,"value":1128},". Trust ",{"type":26,"tag":120,"props":1130,"children":1132},{"className":1131},[],[1133],{"type":32,"value":1046},{"type":32,"value":1135},". Stop using ",{"type":26,"tag":120,"props":1137,"children":1139},{"className":1138},[],[1140],{"type":32,"value":1026},{"type":32,"value":1142}," for anything async.",{"type":26,"tag":61,"props":1144,"children":1145},{},[1146],{"type":32,"value":1147},"Cache aggressively. Users won't notice; your wallet will.",{"type":26,"tag":35,"props":1149,"children":1150},{},[1151],{"type":32,"value":1152},"Six months in, Elsewhere is profitable, the codebase is small (under 12k lines of Swift), and I sleep at night. The SwiftUI + AI stack is no longer experimental. It's just iOS development now.",{"type":26,"tag":35,"props":1154,"children":1155},{},[1156],{"type":32,"value":1157},"— Met",{"type":26,"tag":1159,"props":1160,"children":1161},"style",{},[1162],{"type":32,"value":1163},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":8,"searchDepth":136,"depth":136,"links":1165},[1166,1167,1168,1169,1170,1171,1172,1173,1174],{"id":29,"depth":145,"text":33},{"id":47,"depth":145,"text":50},{"id":96,"depth":145,"text":99},{"id":386,"depth":145,"text":389},{"id":469,"depth":145,"text":472},{"id":589,"depth":145,"text":592},{"id":764,"depth":145,"text":767},{"id":941,"depth":145,"text":944},{"id":1094,"depth":145,"text":1097},"markdown","content:blogs:9. swiftui-ai-narrative-apps-april-2026.md","content","blogs/9. swiftui-ai-narrative-apps-april-2026.md","blogs/9. swiftui-ai-narrative-apps-april-2026","md",{"loc":5},{"_path":1183,"_dir":6,"_draft":7,"_partial":7,"_locale":8,"title":1184,"description":1185,"date":1186,"image":1187,"alt":1188,"ogImage":1187,"tags":1189,"published":21,"body":1193,"_type":1175,"_id":2101,"_source":1177,"_file":2102,"_stem":2103,"_extension":1180,"sitemap":2104},"/blogs/shipping-elsewhere-indie-ai-may-2026","Shipping Elsewhere - An Indie Dev's Honest Postmortem on Building an AI App Solo","Costs, mistakes, App Store rejections, and the moments that made it worth it - the unvarnished story of shipping a Swift-and-AI indie app to the App Store in 2026.","18th May 2026","/blogs-img/blog6.jpg","Building Elsewhere - indie AI app postmortem",[19,15,16,17,1190,1191,1192],"app-store","postmortem","business",{"type":23,"children":1194,"toc":2088},[1195,1201,1213,1296,1301,1306,1312,1317,1327,1332,1338,1343,1348,1383,1395,1405,1411,1416,1444,1449,1454,1463,1469,1474,1507,1519,1525,1530,1535,1540,1558,1563,1572,1578,1583,1758,1770,1776,1786,1791,1910,1922,1927,1945,1950,1956,1961,2014,2020,2025,2053,2058,2064,2069,2074,2079,2084],{"type":26,"tag":27,"props":1196,"children":1198},{"id":1197},"the-numbers-first",[1199],{"type":32,"value":1200},"The numbers, first",{"type":26,"tag":35,"props":1202,"children":1203},{},[1204,1206,1211],{"type":32,"value":1205},"It's May 2026. ",{"type":26,"tag":106,"props":1207,"children":1208},{},[1209],{"type":32,"value":1210},"Elsewhere: Parallel Lives",{"type":32,"value":1212}," has been live for about seven months. Here's the unvarnished snapshot:",{"type":26,"tag":57,"props":1214,"children":1215},{},[1216,1226,1236,1246,1256,1266,1276,1286],{"type":26,"tag":61,"props":1217,"children":1218},{},[1219,1224],{"type":26,"tag":106,"props":1220,"children":1221},{},[1222],{"type":32,"value":1223},"Lines of Swift:",{"type":32,"value":1225}," ~12,400",{"type":26,"tag":61,"props":1227,"children":1228},{},[1229,1234],{"type":26,"tag":106,"props":1230,"children":1231},{},[1232],{"type":32,"value":1233},"Time from first commit to App Store live:",{"type":32,"value":1235}," 4 months, mostly nights and weekends",{"type":26,"tag":61,"props":1237,"children":1238},{},[1239,1244],{"type":26,"tag":106,"props":1240,"children":1241},{},[1242],{"type":32,"value":1243},"Total spend on AI APIs to date:",{"type":32,"value":1245}," $341",{"type":26,"tag":61,"props":1247,"children":1248},{},[1249,1254],{"type":26,"tag":106,"props":1250,"children":1251},{},[1252],{"type":32,"value":1253},"Total spend on services (RevenueCat, Sentry, Apple Developer):",{"type":32,"value":1255}," $138",{"type":26,"tag":61,"props":1257,"children":1258},{},[1259,1264],{"type":26,"tag":106,"props":1260,"children":1261},{},[1262],{"type":32,"value":1263},"Total revenue, gross:",{"type":32,"value":1265}," $4,830",{"type":26,"tag":61,"props":1267,"children":1268},{},[1269,1274],{"type":26,"tag":106,"props":1270,"children":1271},{},[1272],{"type":32,"value":1273},"Total revenue, after Apple's cut + processors:",{"type":32,"value":1275}," $3,290",{"type":26,"tag":61,"props":1277,"children":1278},{},[1279,1284],{"type":26,"tag":106,"props":1280,"children":1281},{},[1282],{"type":32,"value":1283},"Net so far:",{"type":32,"value":1285}," ~$2,800",{"type":26,"tag":61,"props":1287,"children":1288},{},[1289,1294],{"type":26,"tag":106,"props":1290,"children":1291},{},[1292],{"type":32,"value":1293},"App Store rejections survived:",{"type":32,"value":1295}," 3",{"type":26,"tag":35,"props":1297,"children":1298},{},[1299],{"type":32,"value":1300},"It's not a unicorn. It's not failing. It's a small, real, growing thing. And it taught me more about indie iOS development in seven months than the previous five years combined.",{"type":26,"tag":35,"props":1302,"children":1303},{},[1304],{"type":32,"value":1305},"This is the postmortem I'd want to read.",{"type":26,"tag":27,"props":1307,"children":1309},{"id":1308},"what-elsewhere-actually-is",[1310],{"type":32,"value":1311},"What Elsewhere actually is",{"type":26,"tag":35,"props":1313,"children":1314},{},[1315],{"type":32,"value":1316},"A narrative AI app. You give it a short biography. It writes life events shaped by that biography — the job offer, the missed text, the move you almost made. You pick A or B. The universe splits. You walk into the version of you that took the other path and read a 90-word poem of who you became.",{"type":26,"tag":35,"props":1318,"children":1319},{},[1320,1322],{"type":32,"value":1321},"There's no winning. No leaderboards. No FOMO. The pitch I settled on: ",{"type":26,"tag":87,"props":1323,"children":1324},{},[1325],{"type":32,"value":1326},"\"Live every choice you didn't.\"",{"type":26,"tag":35,"props":1328,"children":1329},{},[1330],{"type":32,"value":1331},"If you're picturing a meditation app and a Reigns-style choice game had a quiet, philosophical child — that's about right.",{"type":26,"tag":27,"props":1333,"children":1335},{"id":1334},"mistake-one-i-built-the-ai-before-the-product",[1336],{"type":32,"value":1337},"Mistake one: I built the AI before the product",{"type":26,"tag":35,"props":1339,"children":1340},{},[1341],{"type":32,"value":1342},"For the first six weeks I obsessed over prompt engineering, model selection, event generation quality, narrator voice, the works. I had a beautifully tuned Gemini pipeline before I had a single screen worth shipping.",{"type":26,"tag":35,"props":1344,"children":1345},{},[1346],{"type":32,"value":1347},"This was the wrong order. Users don't experience your prompt. They experience:",{"type":26,"tag":57,"props":1349,"children":1350},{},[1351,1356,1361,1373,1378],{"type":26,"tag":61,"props":1352,"children":1353},{},[1354],{"type":32,"value":1355},"The onboarding flow",{"type":26,"tag":61,"props":1357,"children":1358},{},[1359],{"type":32,"value":1360},"The pacing of the first event",{"type":26,"tag":61,"props":1362,"children":1363},{},[1364,1366,1371],{"type":32,"value":1365},"How a choice ",{"type":26,"tag":87,"props":1367,"children":1368},{},[1369],{"type":32,"value":1370},"feels",{"type":32,"value":1372}," on tap",{"type":26,"tag":61,"props":1374,"children":1375},{},[1376],{"type":32,"value":1377},"Whether the universe map is legible",{"type":26,"tag":61,"props":1379,"children":1380},{},[1381],{"type":32,"value":1382},"How the paywall lands",{"type":26,"tag":35,"props":1384,"children":1385},{},[1386,1388,1393],{"type":32,"value":1387},"I should have built the empty shell with placeholder text first, shipped TestFlight to ten friends, and figured out the ",{"type":26,"tag":87,"props":1389,"children":1390},{},[1391],{"type":32,"value":1392},"feel",{"type":32,"value":1394},". Then plugged in the real model. Instead I had a \"brilliant\" AI and a clunky app, and TestFlight users politely told me the AI was fine but they couldn't tell what they were supposed to do.",{"type":26,"tag":35,"props":1396,"children":1397},{},[1398,1403],{"type":26,"tag":106,"props":1399,"children":1400},{},[1401],{"type":32,"value":1402},"Lesson:",{"type":32,"value":1404}," the AI is plumbing. The product is the user's nervous system. Get the nervous system right first.",{"type":26,"tag":27,"props":1406,"children":1408},{"id":1407},"mistake-two-i-priced-too-low",[1409],{"type":32,"value":1410},"Mistake two: I priced too low",{"type":26,"tag":35,"props":1412,"children":1413},{},[1414],{"type":32,"value":1415},"My initial pricing:",{"type":26,"tag":57,"props":1417,"children":1418},{},[1419,1424,1429,1434,1439],{"type":26,"tag":61,"props":1420,"children":1421},{},[1422],{"type":32,"value":1423},"5 free splits forever",{"type":26,"tag":61,"props":1425,"children":1426},{},[1427],{"type":32,"value":1428},"$0.99 for 30 more",{"type":26,"tag":61,"props":1430,"children":1431},{},[1432],{"type":32,"value":1433},"$2.99 for 100",{"type":26,"tag":61,"props":1435,"children":1436},{},[1437],{"type":32,"value":1438},"$7.99 for 300",{"type":26,"tag":61,"props":1440,"children":1441},{},[1442],{"type":32,"value":1443},"$2.99/month for unlimited",{"type":26,"tag":35,"props":1445,"children":1446},{},[1447],{"type":32,"value":1448},"A friend who runs a successful indie portfolio looked at this and said, simply, \"your monthly is cheaper than your medium pack — nobody will buy the pack.\" He was right. Conversion to the monthly was 8%. Conversion to one-time packs was 1.4%.",{"type":26,"tag":35,"props":1450,"children":1451},{},[1452],{"type":32,"value":1453},"I held this pricing for two months because changing prices feels scary. When I finally raised the monthly to $4.99 and kept the packs the same, pack purchases tripled within a week. The monthly conversion barely moved (8% → 7%). Net revenue per user went up 41%.",{"type":26,"tag":35,"props":1455,"children":1456},{},[1457,1461],{"type":26,"tag":106,"props":1458,"children":1459},{},[1460],{"type":32,"value":1402},{"type":32,"value":1462}," anchor pricing matters more than absolute pricing. If your subscription is cheaper than your packs, your packs are dead inventory.",{"type":26,"tag":27,"props":1464,"children":1466},{"id":1465},"mistake-three-i-underestimated-review",[1467],{"type":32,"value":1468},"Mistake three: I underestimated review",{"type":26,"tag":35,"props":1470,"children":1471},{},[1472],{"type":32,"value":1473},"Apple rejected Elsewhere three times before approving it. Each rejection was reasonable in retrospect:",{"type":26,"tag":1099,"props":1475,"children":1476},{},[1477,1487,1497],{"type":26,"tag":61,"props":1478,"children":1479},{},[1480,1485],{"type":26,"tag":106,"props":1481,"children":1482},{},[1483],{"type":32,"value":1484},"First rejection:",{"type":32,"value":1486}," \"Subscription terms unclear in description.\" I had buried the auto-renewal language. Fix: explicit paragraph in the App Store description with price, duration, and how to cancel. Approved on resubmission.",{"type":26,"tag":61,"props":1488,"children":1489},{},[1490,1495],{"type":26,"tag":106,"props":1491,"children":1492},{},[1493],{"type":32,"value":1494},"Second rejection:",{"type":32,"value":1496}," \"Demo account not required, but onboarding unclear during review.\" The reviewer couldn't get past biography input — they thought it was broken. Fix: pre-populated a sample biography in App Review Notes and added a \"Skip\" button to onboarding. Approved.",{"type":26,"tag":61,"props":1498,"children":1499},{},[1500,1505],{"type":26,"tag":106,"props":1501,"children":1502},{},[1503],{"type":32,"value":1504},"Third rejection:",{"type":32,"value":1506}," \"AI-generated content may produce inappropriate material — please describe content moderation.\" This was the one that scared me. Fix: I wrote a one-page document on system prompt safety rails, content filtering, and what categories are blocked (self-harm, sexual content, real-person impersonation). Pasted it into Notes. Approved within 24 hours.",{"type":26,"tag":35,"props":1508,"children":1509},{},[1510,1512,1517],{"type":32,"value":1511},"The third one is the lesson worth keeping. ",{"type":26,"tag":106,"props":1513,"children":1514},{},[1515],{"type":32,"value":1516},"In 2026, Apple cares about how your AI is constrained.",{"type":32,"value":1518}," They don't expect perfection. They expect that you've thought about it, have rails, and can articulate them. Indie devs who treat AI as \"just call the API and ship\" will hit walls. Have a content moderation document ready before submission.",{"type":26,"tag":27,"props":1520,"children":1522},{"id":1521},"the-thing-i-got-right-shipping-ugly",[1523],{"type":32,"value":1524},"The thing I got right: shipping ugly",{"type":26,"tag":35,"props":1526,"children":1527},{},[1528],{"type":32,"value":1529},"The first public version of Elsewhere had ugly typography in two places, no iPad layout, no haptics on choice buttons, no Live Activities, no widget. It was missing things I considered table-stakes.",{"type":26,"tag":35,"props":1531,"children":1532},{},[1533],{"type":32,"value":1534},"I shipped it anyway.",{"type":26,"tag":35,"props":1536,"children":1537},{},[1538],{"type":32,"value":1539},"Within a week of going live I had three pieces of user feedback that completely rewrote my roadmap:",{"type":26,"tag":57,"props":1541,"children":1542},{},[1543,1548,1553],{"type":26,"tag":61,"props":1544,"children":1545},{},[1546],{"type":32,"value":1547},"\"I keep wanting to share the poem I got\" → I added share cards.",{"type":26,"tag":61,"props":1549,"children":1550},{},[1551],{"type":32,"value":1552},"\"The universe map is beautiful but I can't find my favorite past universe\" → I added pinning.",{"type":26,"tag":61,"props":1554,"children":1555},{},[1556],{"type":32,"value":1557},"\"It feels weird to play this app at the gym, I want it for bedtime\" → I added a \"quiet night\" theme.",{"type":26,"tag":35,"props":1559,"children":1560},{},[1561],{"type":32,"value":1562},"None of these were on my pre-launch roadmap. All of them came from real users. If I'd polished for another two months before shipping, I would have polished the wrong things.",{"type":26,"tag":35,"props":1564,"children":1565},{},[1566,1570],{"type":26,"tag":106,"props":1567,"children":1568},{},[1569],{"type":32,"value":1402},{"type":32,"value":1571}," the version that ships teaches you the version you should have built. Polish after, not before.",{"type":26,"tag":27,"props":1573,"children":1575},{"id":1574},"the-swift-ai-stack-by-line-count",[1576],{"type":32,"value":1577},"The Swift / AI stack, by line count",{"type":26,"tag":35,"props":1579,"children":1580},{},[1581],{"type":32,"value":1582},"For anyone wondering what's actually in there:",{"type":26,"tag":1584,"props":1585,"children":1586},"table",{},[1587,1611],{"type":26,"tag":1588,"props":1589,"children":1590},"thead",{},[1591],{"type":26,"tag":1592,"props":1593,"children":1594},"tr",{},[1595,1601,1606],{"type":26,"tag":1596,"props":1597,"children":1598},"th",{},[1599],{"type":32,"value":1600},"Layer",{"type":26,"tag":1596,"props":1602,"children":1603},{},[1604],{"type":32,"value":1605},"LOC",{"type":26,"tag":1596,"props":1607,"children":1608},{},[1609],{"type":32,"value":1610},"Notes",{"type":26,"tag":1612,"props":1613,"children":1614},"tbody",{},[1615,1634,1652,1670,1688,1706,1724,1742],{"type":26,"tag":1592,"props":1616,"children":1617},{},[1618,1624,1629],{"type":26,"tag":1619,"props":1620,"children":1621},"td",{},[1622],{"type":32,"value":1623},"SwiftUI views",{"type":26,"tag":1619,"props":1625,"children":1626},{},[1627],{"type":32,"value":1628},"4,200",{"type":26,"tag":1619,"props":1630,"children":1631},{},[1632],{"type":32,"value":1633},"Smaller than I expected",{"type":26,"tag":1592,"props":1635,"children":1636},{},[1637,1642,1647],{"type":26,"tag":1619,"props":1638,"children":1639},{},[1640],{"type":32,"value":1641},"Domain / state (Observable models)",{"type":26,"tag":1619,"props":1643,"children":1644},{},[1645],{"type":32,"value":1646},"2,800",{"type":26,"tag":1619,"props":1648,"children":1649},{},[1650],{"type":32,"value":1651},"Pure Swift, no UI",{"type":26,"tag":1592,"props":1653,"children":1654},{},[1655,1660,1665],{"type":26,"tag":1619,"props":1656,"children":1657},{},[1658],{"type":32,"value":1659},"AI service layer (Gemini + on-device)",{"type":26,"tag":1619,"props":1661,"children":1662},{},[1663],{"type":32,"value":1664},"1,400",{"type":26,"tag":1619,"props":1666,"children":1667},{},[1668],{"type":32,"value":1669},"Heavy retry / fallback logic",{"type":26,"tag":1592,"props":1671,"children":1672},{},[1673,1678,1683],{"type":26,"tag":1619,"props":1674,"children":1675},{},[1676],{"type":32,"value":1677},"SwiftData persistence",{"type":26,"tag":1619,"props":1679,"children":1680},{},[1681],{"type":32,"value":1682},"900",{"type":26,"tag":1619,"props":1684,"children":1685},{},[1686],{"type":32,"value":1687},"Migrated from Isar in month 3",{"type":26,"tag":1592,"props":1689,"children":1690},{},[1691,1696,1701],{"type":26,"tag":1619,"props":1692,"children":1693},{},[1694],{"type":32,"value":1695},"RevenueCat purchase service",{"type":26,"tag":1619,"props":1697,"children":1698},{},[1699],{"type":32,"value":1700},"600",{"type":26,"tag":1619,"props":1702,"children":1703},{},[1704],{"type":32,"value":1705},"Receipt validation eats lines",{"type":26,"tag":1592,"props":1707,"children":1708},{},[1709,1714,1719],{"type":26,"tag":1619,"props":1710,"children":1711},{},[1712],{"type":32,"value":1713},"Content moderation + safety rails",{"type":26,"tag":1619,"props":1715,"children":1716},{},[1717],{"type":32,"value":1718},"480",{"type":26,"tag":1619,"props":1720,"children":1721},{},[1722],{"type":32,"value":1723},"Worth every line",{"type":26,"tag":1592,"props":1725,"children":1726},{},[1727,1732,1737],{"type":26,"tag":1619,"props":1728,"children":1729},{},[1730],{"type":32,"value":1731},"Tests",{"type":26,"tag":1619,"props":1733,"children":1734},{},[1735],{"type":32,"value":1736},"1,300",{"type":26,"tag":1619,"props":1738,"children":1739},{},[1740],{"type":32,"value":1741},"Mostly snapshot + parser tests",{"type":26,"tag":1592,"props":1743,"children":1744},{},[1745,1750,1755],{"type":26,"tag":1619,"props":1746,"children":1747},{},[1748],{"type":32,"value":1749},"Misc helpers",{"type":26,"tag":1619,"props":1751,"children":1752},{},[1753],{"type":32,"value":1754},"720",{"type":26,"tag":1619,"props":1756,"children":1757},{},[],{"type":26,"tag":35,"props":1759,"children":1760},{},[1761,1763,1768],{"type":32,"value":1762},"About 60% of the codebase is ",{"type":26,"tag":87,"props":1764,"children":1765},{},[1766],{"type":32,"value":1767},"not",{"type":32,"value":1769}," AI-related. This is normal. AI is a slice. The product is everything else.",{"type":26,"tag":27,"props":1771,"children":1773},{"id":1772},"money-breakdown-may-2026",[1774],{"type":32,"value":1775},"Money breakdown, May 2026",{"type":26,"tag":35,"props":1777,"children":1778},{},[1779,1781],{"type":32,"value":1780},"Most-asked question by other indies: ",{"type":26,"tag":87,"props":1782,"children":1783},{},[1784],{"type":32,"value":1785},"what does it actually cost to run an AI app?",{"type":26,"tag":35,"props":1787,"children":1788},{},[1789],{"type":32,"value":1790},"Monthly operating cost at current scale (~800 monthly active users):",{"type":26,"tag":1584,"props":1792,"children":1793},{},[1794,1810],{"type":26,"tag":1588,"props":1795,"children":1796},{},[1797],{"type":26,"tag":1592,"props":1798,"children":1799},{},[1800,1805],{"type":26,"tag":1596,"props":1801,"children":1802},{},[1803],{"type":32,"value":1804},"Service",{"type":26,"tag":1596,"props":1806,"children":1807},{},[1808],{"type":32,"value":1809},"Monthly cost",{"type":26,"tag":1612,"props":1811,"children":1812},{},[1813,1826,1839,1852,1865,1878,1891],{"type":26,"tag":1592,"props":1814,"children":1815},{},[1816,1821],{"type":26,"tag":1619,"props":1817,"children":1818},{},[1819],{"type":32,"value":1820},"Gemini API (paid tier)",{"type":26,"tag":1619,"props":1822,"children":1823},{},[1824],{"type":32,"value":1825},"$32",{"type":26,"tag":1592,"props":1827,"children":1828},{},[1829,1834],{"type":26,"tag":1619,"props":1830,"children":1831},{},[1832],{"type":32,"value":1833},"RevenueCat",{"type":26,"tag":1619,"props":1835,"children":1836},{},[1837],{"type":32,"value":1838},"$0 (under MTR limit)",{"type":26,"tag":1592,"props":1840,"children":1841},{},[1842,1847],{"type":26,"tag":1619,"props":1843,"children":1844},{},[1845],{"type":32,"value":1846},"Sentry",{"type":26,"tag":1619,"props":1848,"children":1849},{},[1850],{"type":32,"value":1851},"$0 (developer plan)",{"type":26,"tag":1592,"props":1853,"children":1854},{},[1855,1860],{"type":26,"tag":1619,"props":1856,"children":1857},{},[1858],{"type":32,"value":1859},"Apple Developer",{"type":26,"tag":1619,"props":1861,"children":1862},{},[1863],{"type":32,"value":1864},"$8.25 (annualized)",{"type":26,"tag":1592,"props":1866,"children":1867},{},[1868,1873],{"type":26,"tag":1619,"props":1869,"children":1870},{},[1871],{"type":32,"value":1872},"Replicate (AI images)",{"type":26,"tag":1619,"props":1874,"children":1875},{},[1876],{"type":32,"value":1877},"$14",{"type":26,"tag":1592,"props":1879,"children":1880},{},[1881,1886],{"type":26,"tag":1619,"props":1882,"children":1883},{},[1884],{"type":32,"value":1885},"Domain + hosting (this blog)",{"type":26,"tag":1619,"props":1887,"children":1888},{},[1889],{"type":32,"value":1890},"$4",{"type":26,"tag":1592,"props":1892,"children":1893},{},[1894,1902],{"type":26,"tag":1619,"props":1895,"children":1896},{},[1897],{"type":26,"tag":106,"props":1898,"children":1899},{},[1900],{"type":32,"value":1901},"Total",{"type":26,"tag":1619,"props":1903,"children":1904},{},[1905],{"type":26,"tag":106,"props":1906,"children":1907},{},[1908],{"type":32,"value":1909},"~$58/month",{"type":26,"tag":35,"props":1911,"children":1912},{},[1913,1915,1920],{"type":32,"value":1914},"Revenue at the same scale: about ",{"type":26,"tag":106,"props":1916,"children":1917},{},[1918],{"type":32,"value":1919},"$520/month",{"type":32,"value":1921}," net.",{"type":26,"tag":35,"props":1923,"children":1924},{},[1925],{"type":32,"value":1926},"That's a ~9x margin. For an indie app with no marketing budget, no team, and no growth hacks beyond word of mouth. It works because:",{"type":26,"tag":1099,"props":1928,"children":1929},{},[1930,1935,1940],{"type":26,"tag":61,"props":1931,"children":1932},{},[1933],{"type":32,"value":1934},"The on-device foundation model handles the cheap calls.",{"type":26,"tag":61,"props":1936,"children":1937},{},[1938],{"type":32,"value":1939},"Caching kills 18% of cloud calls before they hit Gemini.",{"type":26,"tag":61,"props":1941,"children":1942},{},[1943],{"type":32,"value":1944},"The subscription is structured so power users subsidize light users.",{"type":26,"tag":35,"props":1946,"children":1947},{},[1948],{"type":32,"value":1949},"If I were doing every call on Gemini cloud, my margin would be closer to 2x. The 2026 stack is what makes indie AI apps actually viable.",{"type":26,"tag":27,"props":1951,"children":1953},{"id":1952},"what-id-tell-anyone-starting-now",[1954],{"type":32,"value":1955},"What I'd tell anyone starting now",{"type":26,"tag":35,"props":1957,"children":1958},{},[1959],{"type":32,"value":1960},"Five things, in order of weight:",{"type":26,"tag":1099,"props":1962,"children":1963},{},[1964,1974,1984,1994,2004],{"type":26,"tag":61,"props":1965,"children":1966},{},[1967,1972],{"type":26,"tag":106,"props":1968,"children":1969},{},[1970],{"type":32,"value":1971},"Ship the ugly version.",{"type":32,"value":1973}," You will polish the wrong things otherwise.",{"type":26,"tag":61,"props":1975,"children":1976},{},[1977,1982],{"type":26,"tag":106,"props":1978,"children":1979},{},[1980],{"type":32,"value":1981},"Anchor your pricing.",{"type":32,"value":1983}," Subscription should be more expensive than your second-largest pack.",{"type":26,"tag":61,"props":1985,"children":1986},{},[1987,1992],{"type":26,"tag":106,"props":1988,"children":1989},{},[1990],{"type":32,"value":1991},"Write a content safety doc before submission.",{"type":32,"value":1993}," Apple will eventually ask. Have it ready.",{"type":26,"tag":61,"props":1995,"children":1996},{},[1997,2002],{"type":26,"tag":106,"props":1998,"children":1999},{},[2000],{"type":32,"value":2001},"Use on-device for cheap calls.",{"type":32,"value":2003}," Your wallet and your users' latency thank you.",{"type":26,"tag":61,"props":2005,"children":2006},{},[2007,2012],{"type":26,"tag":106,"props":2008,"children":2009},{},[2010],{"type":32,"value":2011},"Talk to ten users in TestFlight before launch.",{"type":32,"value":2013}," Not your friends. Not Twitter followers. Real users.",{"type":26,"tag":27,"props":2015,"children":2017},{"id":2016},"where-elsewhere-goes-next",[2018],{"type":32,"value":2019},"Where Elsewhere goes next",{"type":26,"tag":35,"props":2021,"children":2022},{},[2023],{"type":32,"value":2024},"Roadmap, in priority order, for the next six months:",{"type":26,"tag":57,"props":2026,"children":2027},{},[2028,2033,2038,2043,2048],{"type":26,"tag":61,"props":2029,"children":2030},{},[2031],{"type":32,"value":2032},"Sign in with Apple + iCloud sync (most-requested feature)",{"type":26,"tag":61,"props":2034,"children":2035},{},[2036],{"type":32,"value":2037},"iPad layout (the third-most-requested, behind sync)",{"type":26,"tag":61,"props":2039,"children":2040},{},[2041],{"type":32,"value":2042},"Apple Intelligence Writing Tools integration in the journal feature",{"type":26,"tag":61,"props":2044,"children":2045},{},[2046],{"type":32,"value":2047},"A Steam build for the desktop crowd (separate codebase pain incoming)",{"type":26,"tag":61,"props":2049,"children":2050},{},[2051],{"type":32,"value":2052},"Localization to JA, DE, ES (top 3 non-EN markets by revenue per install)",{"type":26,"tag":35,"props":2054,"children":2055},{},[2056],{"type":32,"value":2057},"Nothing flashy. Everything earned.",{"type":26,"tag":27,"props":2059,"children":2061},{"id":2060},"the-moment-that-made-it-worth-it",[2062],{"type":32,"value":2063},"The moment that made it worth it",{"type":26,"tag":35,"props":2065,"children":2066},{},[2067],{"type":32,"value":2068},"About two months after launch I got an email from a user. She'd hit a universe in Elsewhere where her alter-ego was named \"the one who said yes.\" She wrote that she'd been holding back from saying yes to something real in her actual life and seeing it written by an AI broke something open.",{"type":26,"tag":35,"props":2070,"children":2071},{},[2072],{"type":32,"value":2073},"She thanked me. For a little app I built between bedtimes and morning meetings.",{"type":26,"tag":35,"props":2075,"children":2076},{},[2077],{"type":32,"value":2078},"That's the thing nobody tells you about indie. The numbers matter. The architecture matters. The App Store matters. But the moment a stranger emails you because your code touched their actual life — that's the thing that gets you to the next build.",{"type":26,"tag":35,"props":2080,"children":2081},{},[2082],{"type":32,"value":2083},"I'm still here. Still building. Still saying yes.",{"type":26,"tag":35,"props":2085,"children":2086},{},[2087],{"type":32,"value":1157},{"title":8,"searchDepth":136,"depth":136,"links":2089},[2090,2091,2092,2093,2094,2095,2096,2097,2098,2099,2100],{"id":1197,"depth":145,"text":1200},{"id":1308,"depth":145,"text":1311},{"id":1334,"depth":145,"text":1337},{"id":1407,"depth":145,"text":1410},{"id":1465,"depth":145,"text":1468},{"id":1521,"depth":145,"text":1524},{"id":1574,"depth":145,"text":1577},{"id":1772,"depth":145,"text":1775},{"id":1952,"depth":145,"text":1955},{"id":2016,"depth":145,"text":2019},{"id":2060,"depth":145,"text":2063},"content:blogs:10. shipping-elsewhere-indie-ai-may-2026.md","blogs/10. shipping-elsewhere-indie-ai-may-2026.md","blogs/10. shipping-elsewhere-indie-ai-may-2026",{"loc":1183},1780164678998]