john mark lowry
§ lab · 2026

duffmate.com

A four-page site for an indie Apple Watch golf app — built to look legitimate, unblock App Store review, and be citable by answer engines. Three weeks, no framework, no CMS, no analytics vendor.

an unpaid family project — my father-in-law built the app; i built everything web-side. all numbers are from the site's own analytics snapshot and are small on purpose.

01 · the moment

My father-in-law — a former Boeing flight-test engineer — built an Apple Watch golf app that counts your shots automatically from the Watch's motion sensors, shows GPS distance to the green and live wind, and syncs a scorecard and shot map to the phone. He needed a website. His stated goals, in his order: look legit; unblock App Store submission (Apple requires a public privacy policy URL and support URL before review — the app literally could not ship without the site); explain it to a golfer who's never heard of it; drive installs; be findable; and give users a real support channel so first-round confusion doesn't become one-star reviews.

Explicit non-goals: no accounts, no web dashboard, no e-commerce, no blog, no Android. Two of the four pages were on the critical path for Apple review, so compliance shipped first and marketing polish second.

02 · the reframe

The brief said “look professional.” The research found the real job: own a niche. Nine competitor sites, separated into app-first (the relevant comparison) and hardware-first (e-commerce storefronts, structurally irrelevant), showed that everyone fights over “golf GPS app” — where a no-name app would be up against products with millions of users — and almost nobody targets “Apple Watch.” So the title tag, the H1, and the hero lead with Apple Watch. Then the pricing assumption changed mid-flight, from a ~$2/month subscription to $5.99 once — which didn't just change a number, it handed the project its wedge in a field of subscriptions and hardware: automatic, no hardware, buy once.

And the compliance chore became the sharpest thing on the site. Rather than run the app's data practices through a policy generator, I read the watchOS source: on-device motion detection, HealthKit write-only (it writes a workout to keep the Watch awake and reads nothing), peer-to-peer sync with no server, plain JSON on disk, and an outbound network surface of exactly two calls — a weather API that receives latitude and longitude, and a course API that receives a search term and coarse coordinates. Grepped for every analytics SDK; found none. Flagged a developer-only CSV logger for disclosure, and flagged the iPhone-side claims as needing confirmation because that source wasn't in the export. The privacy page is defensible line by line — no accounts, no servers, no tracking — in a category where the leaders upload your rounds.

03 · the routes

Static, no framework, because crawlers don't run JavaScript. Every citable fact has to exist in the raw HTML. Astro with static output, extensionless URLs, compressed HTML, inlined critical CSS; the only JavaScript on the page is the analytics tag and one delegated click listener. Homepage: 21 KB of HTML. Everything verifiable with curl.

A 60-line server instead of a hosting platform's static config — so canonicalization travels with the deploy: clean URLs, www → apex 301 preserving the path, HSTS and the usual security headers on every response, and a real 404 that serves the built page (no soft 404s, which the SEO checklist explicitly guards against).

One config module as the launch switch. App Store ID, store URL, a master launched flag, rating value and count, price, and OS minimums — all read from one file, and feeding copy and schema from the same source so they can't disagree. The launch-dependent features — Smart App Banner, download-click events, campaign-tagged store links, schema download URL — shipped to production dormant nine days before launch, rendering identically, doing nothing until the flag flipped. Launch day was a one-line config change and a push.

Honesty enforced in code. Aggregate-rating schema is emitted only when a real rating and count exist (the config comment: never invent these). Every competitor puts a star rating beside the CTA; this site shipped without one because there were no reviews yet, and the requirements doc says so. FAQ schema mirrors the visible answers exactly. And the voice spec — written as a portrait of the maker, “a quiet, dry-witted engineer talking to a golf buddy,” with a banned-buzzword list — refuses to call motion-sensor detection “AI,” because describing the mechanism is more credible than the label. “Let the limits be the brag”: catches your full swings; a tap fixes the odd miss beats 100% accurate.

04 · built for answer engines

The site was built to be ingested and cited by ChatGPT, Claude, Perplexity, and Google's AI surfaces, not only ranked — specified before the copy was written. An opt-in robots.txt, in a year when the default advice is to block: training crawlers, AI search and retrieval bots, and user-triggered agent fetchers each named explicitly, with the two generative-training opt-in switches set to Allow and a comment warning future maintainers not to flip them — because for a no-name indie app, being in the training data is upside, and there is nothing proprietary on a four-page marketing site. Question-style headings that mirror real prompts; self-contained answer paragraphs so a retrieved chunk stands alone; one definitional sentence repeated byte-identically across the H1 area, meta description, OG description, llms.txt, and schema as an entity signal; tables for structured facts; specifics over adjectives. FAQ schema kept after Google deprecated its rich snippet, with the reasoning written down: answer engines still consume structured Q&A. And llms.txt shipped with a clear-eyed note that it's cheap hygiene, probably not a ranking lever.

Then the best technical beat in the project. The robots.txt welcomed every AI crawler — and the CDN's “block AI bots” setting was on at the edge, returning 403 to every one of them before the request ever reached the app. Nothing in the site's own code or output would have revealed it. Found by testing with the actual crawler user agents, verified against the real origin IP to bypass a stale local DNS cache (a leftover registrar CNAME was still resolving in some caches even though authoritative DNS was clean). Setting off; every agent user-agent returned 200.

Three layers a crawler passes through: DNS (a stale CNAME in some caches), the CDN (block-AI-bots returned 403 at the edge), and the app (robots.txt allowed everything) — the two upper layers silently vetoed the third until tested with real user agents
the app said “come in”; the cdn was slamming the door; dns was hiding it. test what the crawler receives.

Off-site, the spec is honest that structure makes you ingestible and authority makes you cited: entity info kept byte-identical across site, schema, store listing, and socials; genuine mentions in the communities answer engines actually source from; and a measurement loop — ask four assistants weekly whether and how the app is cited, and log it. GEO here is an implemented strategy with a measurement plan, not a proven outcome; no citation measurements have been logged yet, and this page won't claim otherwise.

05 · the analytics, built not bought

A dependency-free Node tool pulls three sources into one portable dashboard: Google service-account auth implemented from scratch (JWT header and claims, RS256 assertion signed with node:crypto, exchanged for a token — no SDK), then GA4, Search Console, and the CDN's GraphQL analytics as isolated adapters where one failure never aborts the others. Two honesty mechanisms worth copying: the site launched mid-window, so there is no valid prior period — the dashboard frames everything as “since launch” instead of printing deltas against zero; and it states plainly that edge request counts are bot-inflated (the non-US “traffic” is scanners, not golfers) while GA4's numbers are the human ones — two sources that disagree by 20×, reconciled instead of cherry-picked. The credentials folder sits outside the public site repo by standing rule.

06 · the outcome

Site live June 23; app on the App Store July 10; the flip was one commit. The first 30 days — only five of them post-launch — are instrumentation working correctly, not a growth story: 132 users, 164 sessions, an average engagement of 4 minutes 35 seconds on a four-page site, 13 download clicks, a launch inflection from one to three users a day to thirty, mobile outnumbering desktop (validating the mobile-first layout with a desktop QR code), and the CDN blocking 158 threats. Search Console's first 37 impressions: 34 of them were the privacy page. The compliance page, not the marketing page, was the first thing Google surfaced. The obligation became the differentiator.

Scope, honestly: four routes, fifteen commits, about three weeks, one developer. The build took one pass because the research — nine competitors, the source code, an asset inventory, a voice spec, a full SEO/GEO spec — was finished first. The interest is in decision quality per square inch, not size.

The GEO layer spans your app, your CDN, and your DNS — and two of those three can silently veto the first. A robots.txt full of Allow directives proves nothing about what a crawler actually receives. Test with the real user agents.

the insight
ran onAstro 5 (static output, zero client framework) · 60-line Node static server (sirv) → Railway → Cloudflare · JSON-LD @graph · opt-in AI robots.txt · llms.txt · one config module as the launch switch · dependency-free analytics tool (GA4 · Search Console · Cloudflare GraphQL) · design system reimplemented from a Claude Design handoff

← all work