A pregnancy app, built for dads
One Next.js codebase. Marketing site + gated mobile app + native shells. NHS-aligned content. No cost to the NHS.
What this is
§01Dad & Baby is a free UK pregnancy and early-parenting companion, built specifically for fathers. It covers the journey from positive test through to the toddler years, with NHS-aligned tools and a deliberately dad-first information design.
The pregnancy app, built for dads.
- Audience
- UK fathers — expecting, brand new, or in the early parenting years.
- Distribution
- iOS App Store and Google Play. Web access is gated to native only — desktop browsers are redirected to a download page.
- Business model
- Freemium. Core features and content are free. DadPro is the £4.99/month subscription that unlocks the daily baby tracker, anonymous forum, dark mode, partner sharing, data export, and removes ads.
- Promise to the NHS
- Free for dads. No cost to the NHS.
Architecture in one picture
§02The product is a single Next.js codebase that serves both the marketing site and the gated app, wrapped in a Capacitor shell for the App Store and Play Store builds.
- app/(marketing)/* — public site
- app/(app)/* — gated, mobile-only
- middleware.ts — host + UA gate
- lib/supabase, lib/capacitor
The Capacitor mobile app is a thin native shell — its only job is to embed a WebView and point it at https://app.dadandbaby.co.uk. UI and JavaScript changes ship via git push to Vercel — no AAB or IPA rebuild is needed for product changes. Only native plugin upgrades, splash or icon changes, or capacitor.config.ts changes need a fresh native build.
The Capacitor server.url model
§03The single most important architectural choice in this project lives in capacitor.config.ts:
{
appId: "co.uk.dadandbaby.mobile",
appName: "Dad & Baby",
webDir: "out",
server: {
url: "https://app.dadandbaby.co.uk",
cleartext: false,
},
plugins: { /* push, splash, status bar */ },
}server.url makes the native binary a thin shell that loads the production Vercel build. Most product changes never touch the native projects at all. The things that do require a fresh native build are exactly the things you'd expect:
- Bumps to any @capacitor/* plugin version
- New native plugins added to capacitor.config.ts
- Splash, icon, or status-bar config changes
- Android / iOS metadata (Play Store data-safety, App Store privacy)
- Native code under android/app/src/main or ios/App/App
The cost of this convenience: you can break v1.0 of the app for installed users by deploying a bad web build. There's no version pinning per native release — every native install always points at the latest deployed Vercel app.
Routing and the two-layer mobile gate
§04The same Vercel deployment serves two distinct surfaces, routed by host header:
| dadandbaby.co.uk | Public marketing site. No auth. |
| app.dadandbaby.co.uk | Authenticated app. Desktop UAs redirected to /download?from=desktop. |
Two independent gates
Web access to the app is blocked for desktop browsers via two independent layers — server-side for primary protection and client-side for safety:
Middleware (server)
middleware.ts inspects the User-Agent of every request to app.dadandbaby.co.uk and 307-redirects non-mobile UAs to the marketing site's download page. API routes and /update-password (used from desktop on password resets) are exempt.
MobileGate (client)
A React component that wraps the (app) layouts. It polls Capacitor.isNativePlatform() for up to 1.5s, and if the page is running in a regular browser rather than a Capacitor WebView, it router.replace('/download') — sending the user to the marketing-side install page.
The middleware is the primary gate; MobileGate is the in-app safety net for the edge case where a mobile UA gets through but it's not actually the native shell (e.g. someone opening the URL in mobile Safari).
Auth and onboarding
§05Three sign-in methods, all funnelling through Supabase Auth. Sessions live in HttpOnly cookies, refreshed on every request by the middleware.
- Email OTP
- 8-digit code emailed to the user, verified via supabase.auth.verifyOtp. The primary path — no passwords to remember.
- Apple Sign-In
- @capgo/capacitor-social-login returns an ID token → supabase.auth.signInWithIdToken. Refresh tokens are stored in a custom table so the apple-link edge function can revoke them on account deletion (Apple's required deletion behaviour).
- Google Sign-In
- Same plugin, same flow. The friction-free path for users already on Gmail.
Onboarding — 7 steps
Captured once on first sign-in. A Postgres trigger creates the profiles row; the dashboard checks onboarding_complete and redirects incomplete users back to /onboarding.
- Dad's first name
- Stage — Expecting, Born, or Planning
- Baby count — single or multiples (expecting only)
- Due date or birth date
- Birth details — weight, gender (born only)
- Existing children (optional)
- Terms acceptance
Database — 32 forward-only migrations
§06The schema lives in supabase/migrations/ — forward-only, 32 numbered SQL files telling the story of how the product evolved. Every user-data table has Row-Level Security, with the canonical policy "user can read or write rows where user_id = auth.uid()".
Key tables
- profiles — one per auth user. is_pro, pro_expires_at, active_child_id, onboarding_complete, accepted_terms, theme, forum_alias, stage.
- children — name, due_date, birth_date, is_born, gender, birth_weight_grams, display_order.
- appointments — typed (scan / midwife / antenatal / gp / other), child-linked, dashboard-surfaced.
- birth_plans — wide row of jsonb columns, one per birth-plan step.
- contraction_sessions + contractions — labour timing with 3-1-1 detection.
- dad_checkins / partner_checkins — date-unique mood logs.
- forum_posts / _comments / _upvotes / _reports — DadPro community with moderation.
- sleep_logs, nappy_logs, feeding_logs, activity_logs, medication_logs, illness_logs — daily tracker (Pro).
- apple_refresh_tokens — stored for required-on-deletion revocation.
The migration that mattered most: 029
029_paywall_security.sql tightened the most expensive write path in the database. Before it, the profiles.is_pro column was writable by the user via RLS — meaning a single PATCH request with {"is_pro": true} granted Pro for free. The fix: lock down is_pro so only the RevenueCat webhook's service role can write to it. The client can read it, but cannot change it.
Feature catalogue
§07Built out across pregnancy, early parenting, and post-birth — every feature anchored in NHS guidance where it applies.
- Week-by-week guide (1–40), fruit-size comparison per week
- NHS-aligned 12-step birth plan + PDF export + email-to-midwife
- 3-1-1 contraction timer with labour-stage colour bands
- Mental health hub — PHQ-2/GAD-2 self-check, breathing, dad stories
- Appointments tracker, typed and child-linked
- Partner daily check-in, midwife-question notepad
- Baby names shortlist, scan photo gallery, pregnancy risks
- Hospital bag (Dad / Mum / Baby checklists)
- Nursery checklist (zone-organised)
- Dad's glossary — 55+ terms, flashcard quiz mode
- Car seat safety with UK legal requirements
- Pushchair & pram guide
- Junior ISA, Child Benefit, tax credits
- Statutory Paternity Pay + Shared Parental Leave
- Insurance and protection guidance
- Childcare cost calculator
- Budget planner with persistence
- Daily tracker (Pro): sleep, feeds, nappies, activities, meds
- Development milestones (ASQ-3), growth (NHS percentiles)
- NHS immunisation schedule + health visitor reviews
- Accident prevention, NHS 111 vs 999 emergency guide
- Baby milestones with photos, days-out planner (Google Maps)
Adaptive dashboards
§08The main /dashboard route reads profiles.stage and renders one of three sub-dashboards. The information density, layout, and even the colour cues shift to match where the user is in their journey.
DadPrep guides for pre-conception preparation. A quieter dashboard — fewer widgets, focused on learning.
Week pill + countdown + smart-context strip + mental-health row + toolkit carousel. The v2 layout is feature-flagged via migration 031 so it can be rolled back without a deploy.
Recent tracker activity, last feed / sleep / nappy timestamps, next appointment, quick-actions into child health and the tracker. The dashboard is dense with live data.
Monetisation — DadPro, RevenueCat, AdMob
§09Freemium. Core content stays free; DadPro at £4.99/month unlocks the tracker, the forum, dark mode, partner sharing, data export, and removes ads. The promise to NHS partners is "Free for dads. No cost to the NHS."
The webhook → entitlement flow
- User completes a purchase via the native RevenueCat paywall
- RevenueCat fires the revenuecat-webhook (verified by shared secret)
- The function updates
profiles.is_pro = trueandpro_expires_at - On the next request, the client reads the fresh
profilesrow and the Pro experience activates
On warm app open, the client fires /api/pro/sync which reconciles is_pro against the user's RevenueCat entitlement — covers reinstalls, backup restores, and device migrations where the webhook never re-fired.
AdMob
Banners on a subset of free-tier screens; interstitials with a minimum 10-minute cadence (was 3 minutes, raised after certain Android creatives caused issues). ATT (App Tracking Transparency) is requested contextually before the first ad is shown — never on launch.
Security & privacy
§10A strict baseline on every response, RLS everywhere, and a paranoid stance on what gets written to the client bundle.
Defended
- Strict CSP — self-only by default, Supabase for REST + WSS, YouTube-nocookie for the week-by-week videos, frame-ancestors 'none', object-src 'none'
- HSTS preload, X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin
- Permissions-Policy: camera off, microphone off, geolocation self only
- COOP / CORP: same-origin
- Console stripping in production builds — only console.error and console.warn survive, so auth tokens can't leak into release WebView logs
- RLS on every user-data table, hardened in migrations 028 + 029
- Account deletion via dedicated edge function — cascades the auth user, all related rows, and revokes Apple refresh tokens
- All data in EU Supabase region; privacy enquiries to privacy@dadandbaby.co.uk
Marketing infrastructure
§11Beyond the app itself, the project carries an entire pack of partner-ready collateral baked into the repo. Brand-aligned HTML assets, rendered to PNG by a Puppeteer script that ships in the codebase.
Six brand-aligned editorial assets sized for NHS distribution and school newsletters. Waiting-room screen (1920×1080), tablet portrait (1080×1920), social square, school banner, email card, email header.
All share a _shared.css of brand tokens, fonts, and a faux-phone frame. PNGs are regenerated by node scripts/render-nhs-pack.mjs — the renderer throws a hard error if any <img> fails to load so silent missing-asset PNGs can't ship.
Other collateral lives alongside it: the A5 leaflet (front + back), the NHS partnership one-pager, the Innovate UK and CFI funding appendices, the security one-pager for IT teams, the Play Store feature graphic, Instagram-format posts, and the double-opt-in waitlist email template. Everything brand-aligned, all checked into the repo.
Gotchas & tribal knowledge
§12The things that nearly broke the app — and a few that did, before we learnt about them.
Capacitor bridge injection race
Capacitor.isNativePlatform() returns false on first mount even on real iOS and Android, because the Capacitor JS bridge is injected into the WebView after page JS starts executing. Naive synchronous checks look like 'browser' and would bounce every native user to /download on launch. Both MobileGate and CapacitorProvider work around this with a short polling loop (~100ms × up to 30 attempts). If you ever check isNativePlatform() synchronously without polling, you break the native app for everyone.
app-ads.txt and the /app route matcher
The middleware matcher must explicitly exclude app-ads.txt and ads.txt — otherwise pathname.startsWith('/app') matches them (because the literal characters '/app' are at the start) and the AdMob and AdSense crawlers get redirected. Programmatic ads stop verifying. The fix is one line in the matcher config, but it took a brief revenue dip to spot.
Paywall security — never trust the client
Pre-migration-029, profiles.is_pro was writable by the user via RLS. A single curl to /rest/v1/profiles with {is_pro: true} granted Pro for free. Migration 029 restricted writes to the RevenueCat webhook's service role only. If you ever need to grant Pro to a test user now, do it via Supabase Studio with the service role — not from the client.
server.url means you can break v1.0 for installed users
The AAB and IPA are thin shells loading Vercel. A git push to master deploys to every installed app the next time they navigate. There's no version pinning per native release. A bad web build can take down v1.0 for users who installed months ago — be careful with what ships.
Email confirm + password reset are desktop-allowed
Both /update-password and /email-confirmed are exempt from the desktop-UA redirect, because users open those links from email on whatever device is in front of them — usually a laptop. The pages handle either auth state gracefully.
Two migrations named 015
There are two files prefixed 015 (015_dad_checkins.sql and 015_midwife_priority.sql). The Supabase CLI applies them alphabetically within the same prefix, so treat them as one logical step.
Capacitor plugin thenable hang
Never return a Capacitor plugin proxy object directly from an async function. The proxy looks like a thenable and JavaScript tries to .then() it, which hangs forever. Always destructure or wrap the result.