Appearance
ADR-0011 · Frontend platform architecture (surface-matched: web-DOM + universal RN)
Status: 🟢 Accepted — surface-matched hybrid signed off 2026-07-20 (SEO not to be compromised) · Depends on: ADR-0009 (surfaces), product-vision (Expo mobile, locked 2026-07-14) · Related: ADR-0006, ADR-0002, ADR-0010, Design System
Amendment — 2026-08-02: iOS native delivered; the parity-exception path is retired
Two changes to this ADR's operative content. Everything below that describes the iOS PWA as a current or interim surface is superseded; it is kept because the reasoning that produced the decision is still worth reading, and rewriting it would falsify the record.
- iOS native is built and at parity with Android. The transitional iOS PWA is retired. The supported surface set is, unqualified: Android native · iOS native · Web PWA (RNW). Passages below phrased as "iOS PWA → native in R2" describe the plan as it stood on 2026-07-20, not the state of the platform.
- The platform-specific exception mechanism is withdrawn. All three surfaces ship in sync, permanently;
release.jsoncarries no exception field and G3 requires evidence for all three (release process). The consequence is deliberate: a platform-specific blocker now blocks the whole release rather than shipping two surfaces and following up. A genuinely platform-scoped change — an iOS-only permission string — is recorded in the release's build ledger with a written justification that the other surfaces are unaffected; that is not an exception to parity, it is a change proven to have no effect elsewhere.
Consequences elsewhere in this ADR that assumed a PWA — notably the iOS PWA push limitations — no longer bind: native iOS has real push. The email-OTP fallback they motivated remains valid on its own merits (ADR-0008), not as a PWA workaround.
The one-line thesis
QRSETU is mobile-first, but not uniformly — each surface uses the tool its job demands. Two frontend stacks, one shared core: (1) React web (DOM, shadcn) renders the public pages (SEO-critical service cards) and the admin (dense internal tool); (2) a universal Expo/React Native app is the merchant product (Android native
- iOS PWA + responsive web via RNW). Both consume one platform-agnostic TS core and one shared design-token package, so they stay visually and behaviorally in lockstep. SEO is never routed through React Native Web; the mobile product is never compromised into DOM.
Amended 2026-07-26 — iOS native moves from R2 into R1
The original decision shipped Android native in R1 and served iOS with a PWA, deferring native iOS to R2 to avoid the $99 Apple Developer Program fee. That deferral is withdrawn. Device testing found the iOS experience below standard and visibly unlike Android, and product has made alignment non-negotiable: every feature, component, interaction and animation ships on both natives in the same release, and both builds are requested together.
Two facts changed the calculus:
- Native iOS in R1 costs nothing today. A Mac and an iPhone are available. The Simulator needs no Apple account, and a physical iPhone installs via Xcode free Personal Team signing. The $99 buys distribution (TestFlight, push, Associated Domains) — not development. Constraints to plan around: the free provisioning profile expires every 7 days and the app then refuses to launch, 3 apps per device, no TestFlight.
- The observed drift was never Android-vs-iOS divergence in our code. The app contains exactly one
Platform.OSbranch outside the elevation adapter, and that adapter already emits both iOSshadow*and Androidelevation. What was tested was the iOS PWA, and the web export explains it entirely: nomanifest.json, noapple-*meta, and — the big one — noviewport-fit=cover, soenv(safe-area-inset-*)resolved to zero anduseSafeAreaInsets()returned zeros, colliding content with the notch and home indicator. Native iOS runs the same RN runtime as Android and never had those defects.
Revised surface set: Android native · iOS native · Web PWA. The iOS PWA becomes transitional — held to full parity for as long as it exists, retired once native iOS is distributable. Deferring iOS was cheap when the theory was "the PWA is close enough"; once that turned out to be a packaging artefact rather than a platform truth, carrying two divergent iOS stories cost more than just building the native one.
Revised 2026-07-20 — the recommendation changed (Option A → Option H)
The first draft recommended a single universal codebase for all surfaces (Option A). Product correctly pushed back: forcing the public service cards (the primary growth/virality surface, where SEO + WhatsApp/social previews are business-critical) through React Native Web is a real compromise, and forcing the dense admin into RN is a mild one. The revised, accepted decision is Option H — surface-matched: DOM-web for public + admin, universal RN for the merchant app. This keeps shadcn/ui (for public + admin) and scopes the RN build to the merchant product.
Context
CLAUDE.md's prior state: a React SPA (Vite + React DOM + shadcn/ui on Cloudflare Pages), with mobile a north-star ("design for it, don't build yet"). Product elevated mobile to Day One (2026-07-20):
- Expo / React Native is the primary app architecture for the merchant product (Android + iOS builds).
R1 ships Android native first; iOS native deferred (avoid the $99 Apple fee early); iOS served by a PWA in the interim; R2 adds iOS native (short delay).Superseded 2026-07-26 — see the amendment above. R1 ships Android native + iOS native + Web PWA; the iOS PWA is transitional and retired when native iOS is distributable.- Structural parity for the app: the Android app, iOS app, iOS PWA and mobile web must share the same UX/layout/ workflow. Parity is verified per change, not per release — see
guides/parity-verification.md. - SEO must not be compromised — the public service card is QRSETU's primary growth factor and virality booster.
- TypeScript is non-negotiable for all new code.
The design is therefore not "one stack for everything" but matching each surface to its true requirements.
Match each surface to what it actually needs
This maps the 4 tiers (landing · public · user · admin) to the two stacks:
| Tier / surface | Auth? | Native app needed? | SEO / social critical? | Dense desktop UI? | → Stack |
|---|---|---|---|---|---|
landing / marketing (/, features, pricing, per-domain pages) | No | No | Yes — the primary traffic driver (organic discovery → signups) | No | Stack 1 — React web (RR8, SSR) |
public service cards (/b/:slug, /s/:slug, /w/:slug) | No | No — scan a QR → web page; nobody installs an app to view a card | Yes — Google + WhatsApp/social preview = the viral engine | No | Stack 1 — React web (RR8, SSR) |
| user / merchant app (dashboard, onboarding, editing, my-analytics) | Yes | Yes — this is the mobile-first product | No | Somewhat | Stack 2 — Universal RN (Expo) |
| admin (command center + ops/field-demo) | Yes | No — an installable PWA covers mobile ops/demo | No | Yes | Stack 1 — React web (RR8) + PWA |
landing and public are the two unauthenticated, SEO-critical tiers — both server-rendered on Stack 1 (RR8), both indexed, both behind Cloudflare cache. The auth pages inside the landing tier (/login, /signup) are transactional, not SEO — same RR8 stack, but noindex. Reading the "native app?" column: only the merchant product needs React Native. So Stack 1 (RR8) serves three tiers — landing, public, admin — and Stack 2 (RN) serves only the merchant app.
Programmatic-SEO opportunity (growth lever): because the domain registry exists (ADR-0009), the landing tier can generate per-domain × use-case × city pages (/for/real-estate, /qr-menu-for-cafes, /digital-card-for-electricians-in-pune), each SSR'd and targeting high-intent local search. For a domain-scoped SMB product in India this is one of the largest organic-growth levers, and RR8 SSR + the registry make it cheap to build. Plan it deliberately (post-R1 growth workstream).
Decision drivers
- SEO/social is uncompromisable — the public card must be server-rendered, fast, crawlable, and produce correct OG previews. This is the growth engine; it gets the best web rendering, full stop.
- The mobile product must be genuinely native — true native app + structural parity across app/PWA/mobile-web.
- Reuse the portable core — TanStack Query, Supabase client, RPC/EF services, Zod, entitlement/vertical resolvers run identically in DOM-React and RN; no duplication of data/logic.
- Don't waste the shadcn investment — keep it where it's genuinely best (public + admin dense UI).
- Preserve tier security — the public store build must contain no admin code (here, admin is a different stack, so this is automatic).
- One design language across two idioms — shared tokens + parity discipline keep DOM and RN visually identical.
Options considered
H — Surface-matched: web-DOM (public + admin) + universal RN (merchant app) · [recommended, accepted]
Two stacks divided on a real line — "DOM for the SEO/dense surfaces, RN for the mobile product" — over one shared TS core + one shared design-token package.
- For: public SEO/social is fully solved (dedicated SSR web); dense admin uses shadcn/Radix where it's best-in-class; shadcn is kept, not retired; the RN rebuild is scoped to the merchant app only (smaller); the parity requirement (an app-internal concern) is fully preserved inside the RN stack; public has a single rendering so there's nothing to keep in parity there.
- Against: two UI idioms (shadcn/DOM + RN/NativeWind) to build and keep consistent; more build/deploy surface; one visual seam to manage (a merchant previewing their card in-app is RN-rendered while the live public card is DOM-rendered — must match). Mitigated by the shared token package + a component-parity checklist (or a webview preview of the real card).
A — Single universal codebase (Expo + RN + RNW) for all surfaces
- For: one codebase, one component library, maximum reuse, simplest mental model.
- Against: routes the SEO-critical public card through RNW (weak dynamic-per-slug SSR/social) — compromises the growth engine; forces dense admin into RN (mild compromise); retires shadcn entirely. Not adopted — the public SEO cost is unacceptable given it's the primary growth lever.
B — Dual-stack with the merchant app itself duplicated (shadcn web + separate RN), core-only sharing
- Against: builds the merchant UI twice; parity becomes manual drift — the exact failure the parity requirement forbids. (Distinct from H: H does not duplicate the merchant app — it lives once, in RN, and RNW serves its web.) Rejected.
Recommendation
H — surface-matched hybrid. Concretely:
Stack 1 — React web (DOM, keep shadcn/ui): public + admin
- Public (service cards, landing/auth marketing): server-rendered / statically-generated, SEO-optimized, correct per-slug OG/meta for WhatsApp/social, fast first paint, behind Cloudflare cache (>95% hit target). Framework: React Router v8 (SSR, Cloudflare-native) — shared with admin as one React stack (decided 2026-07-20, see open-Q1).
- Admin (command center + ops): shadcn/Radix, desktop-first for dense authoring (entitlement matrix, field-schema editing, wide tables), and an installable PWA for mobile ops. Behind login; separate deploy from public so the public bundle ships no admin code.
Stack 2 — Universal Expo/React Native: the merchant product app
- One RN codebase → Android native (R1), iOS PWA (R1) → iOS native (R2), responsive web via RNW (mobile + desktop breakpoints). Structural parity holds by construction here (same code across app/PWA/web). Expo Router; NativeWind styling (keeps the Tailwind/
cn()/token model) with gluestack-ui as candidate primitives; Tamagui the alternative.
Shared across both stacks
- The platform-agnostic TS core (data access, services, Zod, entitlements/vertical resolvers) — one source of truth, no duplication of logic.
- A shared design-token package (color, spacing, radius, type, motion, elevation) — the single source of truth for the look, consumed by shadcn/Tailwind (DOM) and NativeWind (RN). Components are implemented twice (once per idiom) but must be visually and behaviorally identical, enforced by the tokens + a parity checklist. This is the discipline that makes "two idioms" safe.
iOS + store billing (unchanged, applies to the merchant app)
The R1 iOS PWA screens are the R2 native iOS screens (eas build --platform ios + Apple account + review — no rebuild). Store-billing stays web-first via Razorpay — the native app is a free companion app with no in-app purchase UI/CTA (Apple 3.1.3(d); avoids Google Play Billing; 0% vs 15–30%), see ADR-0002 · App-store billing compliance. The purchase surface renders on web (Stack 1 / RNW-web) only.
Admin on mobile (revised for the hybrid)
Admin lives in Stack 1 (DOM), so "admin on mobile" is an installable admin PWA (private, behind login — satisfies "privately installed for internal team, never public stores"), not a native binary. It carries the full admin surface except data-dense authoring (desktop-first). Non-negotiable on mobile:
- Template field-demo — the
templates:demo-allcapability (ADR-0006 D2) renders the real public service cards (Stack 1 DOM) with sample data across every domain, so ops pull them up on a phone and pitch prospects laptop-free. Because public cards are already DOM-web, the field-demo is just mobile web — no native admin app required. - High-level analytics always on mobile — product-growth, user-growth, and infra-health tiles (rollups from ADR-0010; infra-health's full form awaits the deferred observability stack).
Consequences
- shadcn/ui is KEPT (not retired) — it powers public + admin (Stack 1). Only the merchant app is (re)built on RN + NativeWind (Stack 2). This reduces the rework versus the earlier "retire shadcn everywhere" framing.
- SEO is first-class — public service cards are server-rendered DOM with per-slug OG/meta and Cloudflare caching; the growth engine is never behind RNW.
- One shared design-token package is mandatory and is the single source of truth for the look across both idioms; every UI/UX implementation (DOM or RN) consumes it — no per-stack color/spacing/radius divergence, ever. A component-parity checklist governs the two implementations.
- The merchant product is genuinely native with structural app/PWA/mobile-web parity; iOS is an R2 packaging step.
- Data layer unchanged across both stacks — RN adds
@react-native-async-storage/async-storage+react-native-url-polyfill(Supabase auth) andexpo-auth-session/native Google sign-in. - Two build/deploy pipelines (a DOM web deploy for public+admin; an Expo/EAS pipeline + Expo-web export for the merchant app) — the accepted cost of best-tool-per-surface.
- The
.dc.htmlClaude Design prototypes remain visual/interaction specs for both stacks; design-system docs (PDPR, design-to-code, screen-reviews) get a pass to cover both idioms and the shared-token contract. - iOS PWA push limits — email-OTP (ADR-0008) sidesteps the worst; push-dependent features need an in-app/email fallback on the iOS PWA until R2 native.
- Monorepo —
packages/core(shared TS) +packages/tokens(design tokens) +apps/{web,mobile}becomes the natural structure; realizes the "extraction is a move, not a rewrite" north-star. Timing is an open question. - R1 is large (archetype platform + entitlements + analytics + E-commerce archetype + two frontend stacks + Android native). Sequence: portable core + tokens + backend → public web (SEO) + merchant app shell → Android packaging + heaviest E-commerce screens late-R1.
- CLAUDE.md updated — the "Mobile" and "UI Hybrid System" sections now describe the two-stack surface-matched model and the shared-token consistency rule.
Open questions for product / engineering
- ✅ Resolved (2026-07-20): React Router v8 (formerly Remix) for BOTH public + admin — Stack 1 is one React framework. Rationale: solo-developer maintainability without compromising SEO. RR8 SSR delivers every SEO fundamental that drives QRSETU's growth — server-rendered HTML, per-slug OG/meta (fixes the WhatsApp blank-preview bug), JSON-LD structured data, reliable indexing, fast LCP — while collapsing Stack 1 to a single React framework (one build, one skillset, full shadcn reuse, no Astro↔React-islands boundary; Astro can't do the dense admin, so "Astro for public" would force two web frameworks for one person). Astro's only edge is marginally better Core Web Vitals (less JS) — one ranking signal, recoverable with discipline, not a growth blocker. Escape hatch: because the SEO fundamentals are framework-independent, the public pages alone can later migrate to Astro if measured CWV becomes a competitive bottleneck — a surgical, reversible optimization, not a rewrite. Next.js ruled out (OpenNext adapter tax on Cloudflare — second build step, feature-lag, KV eventual-consistency — with no upside here). RR8 is first-class on Cloudflare and free. Cost-neutral vs all alternatives.
- Merchant styling/component lib: NativeWind + gluestack-ui (recommended — preserves the Tailwind/token model) vs Tamagui vs in-house primitives on NativeWind.
- Design-token package shape: a framework-neutral token source compiled to both a Tailwind config (DOM) and a NativeWind/JS theme (RN) — confirm the build pipeline (e.g. Style Dictionary or a hand-rolled TS token module).
- Monorepo timing: restructure to
packages/*+apps/*now vs after R1 core lands. - Apple IAP strategy (R2): confirm the web-routed-upgrade (companion) approach before the iOS native build.
- PWA offline scope (merchant app + admin PWA): full vs read-only cached vs online-only.
Related
- Product-vision (Expo mobile, locked 2026-07-14) — operationalized here, moved from "later" to Day One.
- ADR-0009 — surfaces (card/site/biolink) are archetype-orthogonal; the card renders on Stack 1, the merchant editing on Stack 2.
- ADR-0006 — capabilities gate admin + the mobile field-demo.
- ADR-0002 — web-first Razorpay / free-companion billing.
- ADR-0008 — email OTP avoids the iOS-PWA push limit.
- ADR-0010 — analytics rollups feed both the admin dashboards and merchant "my analytics".
- Design System (PDPR, design-to-code, tokens) — the shared-token package is the cross-idiom source of truth.