# Building a bedtime-story app for my daughter — and taking it to the App Store

> Designing and building MomDadTales end to end: an AI story pipeline, a catalogue API, media delivery, an iOS app with subscriptions, and the App Store release.

- Project: MomDadTales
- Role: Independent — designed and built end to end
- Period: March – September 2026
- Topics: Swift, SwiftUI, StoreKit 2, App Attest, Node.js, TypeScript, PostgreSQL, Fly.io, Cloudflare, S3, OpenAI, ElevenLabs

URL: https://jovev.com/work/momdadtales
Author: Dragan Jovev (https://jovev.com)
Updated: 2026-09-30

MomDadTales is an iOS app of illustrated bedtime stories, which a parent can read aloud or play as narration. It began because I wanted better bedtime stories for my daughter. I built it on my own, from the story pipeline to the App Store release.

[momdadtales.com](https://momdadtales.com) · [App Store](https://apps.apple.com/app/id6777031051)

## Context

A parent adds a profile for each child, with an age and interests, and the app suggests stories that fit. Each story has a cover, page illustrations and a narrated voiceover, and the app picks up the child's age and interests from that profile.

The first commit was in June 2026. Version 1.2.0 went live on the App Store in September 2026, with a catalogue of more than 50 English stories. My daughter and I use it every night.

## Role

I did all of it: product design, the iOS app, the backend API, the story-generation pipeline, media storage and delivery, subscriptions, the marketing website, and the App Store submission.

### How I worked

I built it with an AI coding agent, and it changed what one person can ship. Four generation services, an API, an admin panel, a website and a universal iOS app took about three and a half months — work that would otherwise have needed at least three people. The speed was real, but it depended on writing things down. Project rules, specs and a written plan for each larger task kept the output consistent. When a request was vague, the code that came back was vague too. Review stayed my job. Subscriptions, App Attest and the release requirements are places where plausible code is not the same as correct code.

## Constraints

- **The audience is children.** No story can contain violence, fear, shame or an unsettling ending, and a parent has to be able to trust every story without reading it first.
- **Bedtime doesn't wait.** A story has to open right away, keep working on a weak connection, and never stop in the middle of the narration.
- **Generation costs money.** Every story pays for text, 6 to 14 illustrations and a narration. At the start that was over a dollar a story, and a catalogue is hundreds of stories.
- **I'm one developer.** Every part of the system has to be something I can run and fix myself.
- **App Store rules.** Subscriptions, privacy labels and child-directed content each come with their own review requirements.

## Architecture

### Product architecture

```
Offline, on my machine
  planner ─► orchestrator ─┬─► text service      (OpenAI + safety linter)
                           ├─► artwork service   (Gemini image model)
                           └─► voiceover service (ElevenLabs)
                           │
                           ▼
                  S3 (media) + PostgreSQL (stories)
                           ▲
  admin panel (local) ─────┘   review, fix, regenerate

Online
  iOS app ─► Express API on Fly.io ─► PostgreSQL
     │         (App Attest, quotas)
     └──────► Cloudflare CDN ─► S3
```

The main boundary is between creating stories and serving them. Stories are generated in batches on my machine, checked, and then published to the database. The public API only reads from that catalogue and ranks it. It never calls a model.

### Media storage and delivery

Media lives in S3, and the app loads it through Cloudflare. For each cover and illustration, the CDN resizes the image and converts it to WebP from the URL parameters, so the app asks for a small, medium or large version and I store only one original.

Media files are never overwritten. When I replace an image in the admin panel, it gets a new key, for example `page-3-v2.png`, and the database points to that key. Because a key's content never changes, every object is served as `immutable` and cached forever, and I never need to purge the CDN.

### Caching and offline behavior

The app keeps two stores on the device. One is a disk cache that the system may clear when it needs space. The other is a library in Application Support, which the system never clears. When a story is opened, or a parent taps "Save for later", the app downloads the whole story (text, cover, illustrations and narration) into the library. From then on the story works with no connection.

When the network fails, the home screen switches to what is on the device. It does the same when the API rejects the app or limits its requests, because a parent can't do anything about either, and cached stories are more useful than an error message.

## Key decisions

### A curated catalogue instead of generating stories on demand

Asking a model for a story "about my daughter" was the obvious design, and I rejected it for four reasons:

- **Safety.** A generated story reaches a child only after it has passed automated checks and I have read it.
- **Cost.** Text, illustrations and narration are paid for once per story, not once per request.
- **Speed.** A full story takes minutes to generate, and at bedtime a story has to be ready immediately.
- **Quality.** Keeping characters consistent across pages takes reference images and retries, and that only works offline.

The personalization comes from ranking instead. The API scores the catalogue by the child's age and interests, by the thumbs-up and thumbs-down votes from all users, and by what that family has already read, and it returns the results in themed groups.

### Bringing the cost of a story down

A story costs what its text, its 6 to 14 illustrations and its narration cost, and nothing recovers that if the story is bad. At the start it was over a dollar. It is now about **$0.30**.

Getting there was not a single optimisation. I ran combinations of models and prompts against the same stories and compared the results, looking for the cheapest mix that still cleared the quality bar — characters consistent across pages, text that passes the safety checks, narration worth listening to. Cheaper models were fine for some steps and visibly worse for others, and the only way to know which was which was to generate and compare.

The whole published catalogue, more than 50 stories including every re-run and revision, cost around **$100** to produce. The live system — Fly.io, Postgres, S3 and Cloudflare — runs on about **$30 a month**.

### The story-generation pipeline

A planner chooses the next batch of stories by rotating through age, interests, a moral theme and a set of characters. Every combination it has used is recorded in a file kept in git, so no combination is generated twice.

The orchestrator then runs each story through three services:

- **Text.** OpenAI returns structured JSON: the title, pages and a description of each illustration. The length is set per age group. A content checker and an optional moderation check reject violence, fear, shame-heavy parenting and unsettling endings. When a story fails, the service returns an error, and the story is not published.
- **Artwork.** A Gemini image model draws the cover and the page illustrations. Each character has a reference sheet, and each story has a short style guide covering the art style, colours and recurring objects, so the characters look the same on every page. The framing changes from page to page, so the pages don't all look alike.
- **Voiceover.** ElevenLabs narrates the story from a script built from the title, the pages and the ending.

If a step fails, the orchestrator undoes what that story had already written to S3 and the database, so a half-finished story never appears in the catalogue.

For the stories that are published, a small local admin panel lets me edit text, replace an illustration or generate a new narration. Nothing is saved until I accept the result.

### Subscriptions and identity without accounts

The app has no accounts and no login. Each install creates a random ID and keeps it in the Keychain, so it survives a reinstall. Once a parent subscribes, the app also sends the StoreKit original transaction ID, which is the same on all of that person's devices. The backend uses the transaction ID when there is one and the install ID otherwise, so reading history follows a subscriber from phone to iPad.

The app requires a subscription (weekly, monthly or yearly, with a three-day free trial). On launch, the app shows no paywall until StoreKit 2 has checked the entitlements, so a subscriber never sees it flash on screen. Restore Purchases and the `Transaction.updates` listener keep the subscription state correct across devices and renewals.

Children's names stay on the device. The backend receives only an age and interest tags.

### Protecting the API

The stories are the expensive part of the product, so the API can't be open to everyone. Every request needs a token that the app earns through App Attest, which proves the request comes from a genuine install of the app. Each device has a daily limit and a per-minute limit. A device over the limit is slowed down with a `429`, not blocked, so a false positive makes a real family's app slower, not broken.

Enforcement came in two phases. First the gate checked tokens and logged failures without rejecting anything. Once the logs looked right, I switched it on. The switch is set in two places, so removing one setting can't turn the gate off by accident.

### What changed during development

- **Analytics came in and went out.** I added Mixpanel, then removed it entirely before launch. The app now includes no third-party SDK, and the privacy label lists only data the app needs to work.
- **The Kids category was a no.** The parent is the one who uses the app, so it is listed under Books and Education. That avoids parental gates on the paywall and the Kids category's stricter, hard-to-reverse rules. The privacy policy still covers children's data.
- **The admin panel came late.** At first, fixing a published story meant writing scripts against the database. Once the catalogue grew, I built a proper tool for it.

## Designing for failure

### When content generation fails

This happens before any user is involved. A story that fails the safety checks is never published. A failed illustration or narration undoes that story's writes and leaves its combination marked as planned, so I can retry it. Illustrations and narration can also be generated later for a story that is already published.

### When audio is unavailable

The Listen option appears only when a story has a narration, so reading aloud always works. Narration plays from the downloaded file when there is one and streams from the CDN otherwise. It keeps playing with the screen locked and can be controlled from the lock screen.

## App Store release

The release took three release candidates. The rejection was not about code. The App Store description was missing a link to the Terms of Use, which Apple requires for auto-renewable subscriptions even when the paywall already shows the link.

Most of the launch work was outside the app. The marketing site's privacy page had to be publicly reachable. The support address needed a working mailbox. The privacy labels had to match the privacy manifest. Screenshots were needed for both iPhone and iPad. A remote version file on the CDN lets me force an update when the API changes.

## Outcome and lessons

MomDadTales is live on the App Store with more than 50 English stories, and it's part of our bedtime every night. It costs about $30 a month to run, and about $0.30 to add another story to the catalogue.

**What I'd do differently:**

- **Start distribution on day one, alongside development.** Building the app was the part I could control. Finding the parents who would use it takes longer, and that work should start with the first commit, not after launch.
- **Start the App Store work earlier.** The listing, legal links, privacy labels and support channels caused more delay than any code did.
- **Decide on analytics before adding it.** Adding Mixpanel and then removing it cost time. I would start with no analytics and add first-party metrics only when a real question needs answering.
