How I build product pages for my iOS apps
Each of my iOS apps, Weeks Left and Penlock, has a small site: a product page, a privacy policy, and terms of use. The App Store requires a privacy policy and a support link, and Google Play requires a privacy policy. The apps also link to these pages from Settings, and App Review reads them. I’ve built two of these sites and fixed both several times. This post explains the setup I use now.
Keep the site in the app’s repo
The site lives in the app’s own repo, in a docs/ folder that GitHub Pages serves:
docs/index.html product page
docs/privacy/index.html privacy policy
docs/terms/index.html terms of use
Putting each legal page in its own folder gives clean URLs like /privacy instead of /privacy.html.
A repo’s Pages site lives under /<Repo>/, and that path is case sensitive. If your main site has a custom domain, the app site gets it too. Penlock’s site lives at tonybach.io/Penlock/ with no extra setup. A link that starts with / goes to the main site, not the app’s. I broke the legal page links this way when I first moved them to clean URLs. Months later, I found the Penlock logo still pointing at /. Relative links (href="privacy", or href="../" from a legal page) avoid the problem.
Use one set of URLs everywhere
Pick the URLs once and reuse the exact strings:
<site>/<Repo>/as the website and support URL<site>/<Repo>/privacy/for the privacy policy<site>/<Repo>/terms/for the terms
The URLs appear in many places: the Privacy Policy and Terms rows in the app’s Settings, the paywall, every language of the App Store listing, and Google Play’s store settings. In App Store Connect, the privacy policy URL belongs to the app info. The support and marketing URLs belong to each version. Every language needs its own copy of all three URLs. When I added a second language to one listing, its privacy URL was empty.
After Pages deploys, I run curl -sI on each URL and look for a 200 before pasting it into a store form.
Build a product page that matches the live app
The top of the page has the name, a one-line promise, the App Store badge, and a screenshot or two. Use Apple’s official badge. Penlock used a custom button and then a hand-traced SVG before I switched to the real one. App Store Connect shows the app’s numeric ID before launch, so the badge can link to https://apps.apple.com/app/id<app id> from the start.
Below that come the features. I list only the features in the version that people can download, and I never write “coming soon”. I also keep the number of cards a multiple of the grid’s columns, so no row ends with a single card.
Pricing comes straight from the code: what’s free (Penlock gives you 10 entries), what the paid tier unlocks, and the plan types. If there’s a paid tier, the app is “free to start”, not “free”. I show the US price with a note that the App Store shows the price in your local currency.
The privacy section has to say what leaves the device. “Anonymous crash reports, which never include what you write” is true for an app with Crashlytics. “Zero telemetry” is not, and Penlock’s page said exactly that for seven months after Crashlytics shipped.
In the <head>, add the Open Graph and Twitter tags, so links to the page get a preview. Use an absolute URL for og:image. Use the app icon as the favicon on every page.
I made two layout mistakes on the first site. I fit the hero to the screen height with overflow: hidden, and it cropped the content on phones. A min-height of 100dvh and a page that scrolls work better. Screenshots stretched until I gave them max-width, height: auto, and object-fit: contain.
Keep the legal pages accurate
The privacy policy and terms use the same fonts and colors as the product page, with a link back to the product page. These rules keep them accurate:
- Name the owner the same way everywhere: the policy, the terms, the page’s copyright line, the app’s copyright string, and the store listing.
- Update the “Last updated” date on every edit, and check the year. My first pages had the wrong one.
- Give each service that receives data its own section in the privacy policy, for example purchases, crash reports, sync, and downloads. Say what it sends, why, and how long it’s kept.
- Apps with auto-renewing subscriptions must link to terms of use from the paywall, Settings, and the listing (App Store guideline 3.1.2). Apple’s standard license agreement is enough, so a terms page is optional. I wrote my own terms for Weeks Left. They cover billing, auto-renewal and the 24-hour cancellation window, how to cancel, upgrades, refunds through the store, and restoring purchases.
These are legal texts on a public site, so I read every change before it goes out.
Check the page after each release
A product page can become wrong without anyone noticing. Before I touch the copy, I check what’s actually live with Apple’s public lookup endpoint:
curl "https://itunes.apple.com/lookup?id=<app id>&country=us"
It returns the live version, description, release notes, price, and languages. The repo’s main branch often has features that haven’t shipped yet.
Privacy takes more work. Six places have to agree: the product page, the privacy policy, the App Store description, the App Privacy label, the app’s privacy manifest, and Google Play’s data safety form. Every time the app adds an SDK, check all six. Two of them are hard to change. The App Store description belongs to a version, so it can change only while that version is still editable. The App Privacy label has no public API, so I check it manually in App Store Connect.
Capture every screenshot in light and dark
Screenshots become outdated faster than anything else on the page. Penlock’s page still showed a calendar header with five weekday letters. That bug was in the app from February, and I had fixed it a few days earlier:
I take screenshots in the simulator from a current build, with the app’s debug sample data and the status bar set to 9:41. Both sites switch to a dark theme in dark mode. So every screenshot comes in a light and a dark version of the same screen, with the same data:
xcrun simctl status_bar <udid> override --time "9:41" --batteryState charged --batteryLevel 100
xcrun simctl io <udid> screenshot light.png
xcrun simctl ui <udid> appearance dark
xcrun simctl io <udid> screenshot dark.png
The browser picks the right one with a <picture> element:
<picture>
<source srcset="screenshot-dark.png" media="(prefers-color-scheme: dark)">
<img src="screenshot.png" alt="...">
</picture>
Compare the hashes of the two files once. One of Penlock’s first “dark” screenshots was a copy of the light one.
Get the skill
I now do all of this with Claude Code and a skill that holds everything above. The skill is build-app-product-page in my agent-skills repo. It covers building a site from scratch and updating one after a release. I tested it on made-up apps against an agent without it. Without the skill, the agent missed the App Store description, never checked the free tier in the code, and suggested “coming soon” labels.
To add it to Claude Code:
claude plugin marketplace add tonybach/agent-skills
claude plugin install agent-skills@tonybach-skills