Offline-first mobile inspection app for Nordic real-estate consultants
Stratas team project
Built a React Native inspection app that works on-site with no signal, generates law-conformant PDF reports server-side, enforces multi-tenant data isolation at the database boundary, and sells its subscription outside the app – security-first and 30%-tax-free before the first paying customer.
Stratas team project
Delivered by the Stratas team and shown here as part of the wider Stratas portfolio.
Open full-size image ↗
Situation
Nordic real-estate consultants doing move-in, move-out, and interim property inspections work with a specific set of constraints:
- The inspection happens physically on-site, often in buildings with no cellular signal (basements, concrete stairwells, cabins outside town).
- The output has to be a PDF conformant to husleieloven / hyreslagen / lejeloven – the Norwegian, Swedish, and Danish rental laws – because the signed report is a legal artefact in a dispute.
- The firms running these consultants are small-to-mid-size businesses with 5 to 500 consultants, not enterprises; the buyer wants a SaaS subscription, not a custom build.
- Everything about the data is sensitive and regulated – tenant personal data, photos of other people's homes, signatures.
Existing tooling in the sector is a mix of Word templates, Excel sheets, and a handful of competitors with web apps that assume signal and don't handle offline well. The gap is a mobile-first, offline-capable, law-aware tool.
Task
Four decisions we owned, up front:
- Native (Swift + Kotlin, two codebases) or cross-platform (RN / Flutter, one codebase). A two-person company can't carry two mobile codebases; the question was whether cross-platform could cover the hard parts (camera, offline sync, background upload).
- Offline-sync strategy. Server-authoritative pulls on reconnect, CRDT-style merge, or a mutation queue with last-write-wins? Which pattern honours the business rule that the field consultant's record is the record?
- Monetisation path. Subscription via Apple IAP / Google Play Billing (30% tax, Sign-in-with-Apple requirement, consumer-UX inside the app) or via a marketing site (no app-store tax, but a different flow)?
- Where the tenancy boundary lives. Same question as every multi-tenant SaaS, with an extra wrinkle: the mobile app ships with the database client embedded, so the boundary has to hold even when a well-informed user controls the client.
Action
Architecture
Cross-platform via Expo + Legend-State v3. Expo SDK 52 managed workflow + React Native 0.76.5 + TypeScript + Expo Router (typed routes) + NativeWind. Legend-State v3 with syncedSupabase provides an observable store that is also a mutation queue – writes land in the local store immediately and drain to Supabase in background, with expo-background-task keeping the drain alive even if the app is backgrounded. The field consultant's record is the record because the mutation queue preserves local writes until Supabase confirms them.
Multi-tenant Postgres RLS enforced on firm_id. Nine tables and three storage buckets, all with per-row RLS tied to the authenticated user's firm_id. pgTAP boundary tests are mandatory before merge: if a change to a policy would let one firm read another firm's rows, the test fails and the PR is blocked. The RLS boundary holds even though the mobile app is a Supabase client – the database refuses the cross-firm read regardless of who's asking.
Signed PDF reports via Trigger.dev + @react-pdf/renderer. A single named task generate-inspection-report renders the husleieloven template to a PDF, uploads to Supabase Storage, and returns a signed URL. Max duration 300 s, 3 retries with exponential backoff. Keeping PDF generation server-side means legal templates live in one place, versioned.
Monetisation outside the app. The app only authenticates (Supabase magic-link). Subscription is sold via the Next.js marketing site at knokko/marketing/, scaffolded with legal placeholders + delete-account surface. No StoreKit, no Google Play Billing, no consumer subscription UI inside the app – which keeps a mobile B2B product out of the 30% Apple IAP tax, out of the Sign-in-with-Apple requirement, and out of the "subscription for a tool our employer pays for" UX trap.
Alternatives considered and rejected:
- Native per platform. Two codebases is twice the maintenance for a two-person shop. Legend-State + Expo covered the hard parts (camera, offline sync, resumable upload) convincingly enough that this never got close.
- CRDT merge for offline. The business rule is "the consultant on-site is right"; a merge strategy that lets a sync pull overwrite a field write would violate the invariant. Mutation queue with last-write-wins (where last = the physical consultant) is the honest model.
- Flutter. Would have worked. Rejected on team-skills grounds; the Portfolio's React depth transfers directly to RN.
- Apple IAP for subscription. 30% tax + the IAP tax reaches B2B tools too + Sign-in-with-Apple requirement + consumer-UX disclosures. All of that for a SaaS the firm's finance team was going to pay by invoice anyway.
- US Supabase region.
CLAUDE.mdforbids it: EU residency (Frankfurt or Stockholm) is a hard constraint for the kind of data this app handles.
Implementation
- Full offline-first write path, demonstrated end-to-end. Property list, room/checklist CRUD, two-party signature capture, photo capture, and the photo upload queue all go through Legend-State observables with Supabase as the sync destination.
expo-background-taskkeeps the queue draining when the app is backgrounded. Not a mock; verified on a photo-heavy write surface. - TUS resumable uploads for photos > 6 MB. Inspection photos run large; a flaky signal with a 20 MB photo on a 3G link would fail a plain POST. TUS resumability turns a failed upload into a resumed one.
- pgTAP boundary tests on every RLS-touching PR. The tests cover
firm_idisolation on 9 tables + storage buckets. A boundary regression fails CI; a security reviewer agent pass is required on every auth / RLS / signature / photo change. - Expo-camera + photo compression + metadata stripping. Photos are captured through
expo-camera, compressed before enqueue, and stripped of GPS EXIF – the client doesn't need to know whose home was photographed. - i18n with Norwegian as source.
nb-NOis the source language;sv-SE,da-DK, andenare downstream drafts awaiting paid native review. Inverts the usual Nordic-product mistake of writing English first and treating NO as a translation. - Autonomous build cadence via Fleet backlog. Cycle 6 (primitives + feedback + confirm pair + flicker + token) delivered 8 PRs in one session on 2026-10-04 against a
knokkoFleet-backlog project. Button, ScreenHeader, MutationErrorBanner, useMutationFeedback, ConfirmSheet, plus two UI fixes – all merged in one bundled cycle.
Verification
- pgTAP boundary tests on 9 tables + 3 storage buckets passing on every PR; a
firm_idleak fails the pipeline. - End-to-end offline test (manual, airplane mode): property list → room CRUD → signatures → photo capture → background upload after reconnect. Mutations survive the gap, upload queue drains correctly.
- Trigger.dev
generate-inspection-reporttask validated against the husleieloven template with sample data; 300 s / 3-retry envelope exercised on an intentionally-slow render. - Security-reviewer agent pass required on every auth / RLS / signature / photo change per workspace rule. Catches include EXIF-GPS removal, signed-URL expiry, and storage-bucket policy drift.
Result
Pre-launch. Shipped: repo skeleton, Expo scaffold, i18n bootstrap, Supabase + Sentry + PostHog client stubs, schema + RLS + pgTAP tests, magic-link auth, property list, Legend-State sync, photo capture + upload queue, Trigger.dev PDF task, room/checklist CRUD write-side, two-party signature capture, comparison + photos observable, marketing site + legal placeholders.
Pending: PDF husleieloven template polish, offline/airplane acceptance test, native-reviewed SE/DK translations, accessibility pass, TestFlight / Play submission, Stripe subscription wiring on marketing site, v1.0 launch. Human-blocked gates: Apple Developer, Google Play, D-U-N-S, trademark, lawyer review of templates.
Second-order effects:
- The security story is already defensible. Multi-tenant RLS with pgTAP gates and EU residency are not retrofitted; they are the first thing a procurement officer will ask about, and the answer exists before the first customer does.
- The B2B monetisation path keeps the mobile app simple. The app doesn't carry subscription state, billing surfaces, or Apple's consumer-subscription disclosures. All of that lives on the marketing site, which is where buyers already look.
- The Fleet-backlog cadence makes autonomous mobile work tractable. Cycle 6 bundled eight PRs behind a shared backlog project; the backlog is the brief, the branch, and the handoff artefact.
What we would do differently: write the PDF template as a tested Trigger.dev task from the first inspection form, not from the first write-side CRUD. The template is the legal artefact; the UI is tomorrow's UX problem. Having the signed-PDF path working early would have forced the correct data shape on every upstream form.
Skills evidenced
- Offline-first mobile write path on React Native – Legend-State v3
syncedSupabaseobservables, mutation queue,expo-background-taskdrain, verified end-to-end on photo writes. - Multi-tenant Postgres RLS enforced on
firm_idwith pgTAP gates – security-first before the first paying customer. The boundary holds even against the client-side Supabase client. - Expo + EAS Build + typed Expo Router – one codebase, two platforms, type-safe routes, EAS pipeline for Build + Submit.
- Mobile B2B monetisation without StoreKit / Play Billing – subscription on the marketing site; app only authenticates; no 30% tax, no Sign-in-with-Apple requirement.
- Server-side signed PDFs via Trigger.dev – single named task,
@react-pdf/renderer, Supabase Storage signed URL; legal templates versioned in one place. - EU-first data residency as a hard rule – Frankfurt or Stockholm; US regions forbidden by workspace rule. Not a marketing claim, a
CLAUDE.mdguardrail. - Norwegian-source i18n – NO as source, SE / DK / EN downstream drafts awaiting paid native review.
Portable lessons
- For a mobile B2B tool, sell the subscription outside the app. The 30% Apple tax, the Sign-in-with-Apple requirement, and the consumer-subscription UX pattern are all costs that evaporate the moment payment lives on a marketing site your buyer was going to use anyway.
- If the mobile app is a Supabase client, the RLS boundary is your only real tenancy guarantee. Trust-the-client is not a model;
firm_idRLS with pgTAP gates on every merge is. - Pick the sync model that honours the business rule, not the one with the nicest conflict story. "The on-site consultant is right" rules out CRDT-merge; a mutation queue with last-write-wins fits the invariant.
- Legal artefacts belong on the server from day one. The husleieloven PDF is the thing a judge might read; it should be a tested server-side task before any UI touches it. (Still fixing this one on Knokko.)
Want to discuss the decisions behind it?
Over fifteen years of consulting and growth, and hands-on AI delivery from discovery to daily operations. I can share my CV and talk through how I connect business outcomes with systems that run.
Available for a chat. In person in Stavanger, or remotely worldwide.