Inspiration

When someone passes, their story doesn't end — it scatters. A photo on a niece's phone. An anecdote a cousin told once at Thanksgiving. A voicemail no one can find anymore. A Facebook timeline that goes dormant and eventually gets memorialized into a static page nobody visits.

I watched this happen in my own family. The memories were there — distributed across people, devices, and platforms — but there was no home for them. No place where a grandchild, decades later, could go and understand who their grandfather was, not just when he died.

That gap became the seed for StillThere (working title: Legacy). Not a memorial page. Not a social network. A collaborative digital archive where families preserve a loved one's life story — memories, milestones, voice, photos, relationships — so it keeps growing for generations.

The guiding question became a filter for every product decision:

Will this help preserve someone's life story for future generations?

If yes, it belongs. If no, it doesn't. No followers. No engagement metrics. No dopamine loops. Just the story.

What it does

How we built it

Monorepo architecture (pnpm workspaces):
apps/
web/ React 19 + Vite + TanStack Router + Tailwind
mobile/ Expo SDK 57 + React Native 0.86 + Expo Router + NativeWind
functions/ Firebase Cloud Functions (TypeScript)
packages/
core/ Shared Zod schemas, Firebase services, RBAC, AI, search, offline
ui-tokens/ Shared design tokens (colors, typography, spacing)
The key architectural decision: there is no Memorial entity. A person is a person — with a status field (living, deceased, historical). A memorial is simply one view of a person. This made family trees, genealogy, AI, and collaboration almost free, because everything is already a graph:
User → Family → Person → Relationship → Memory → Media → Event
Shared contract: Both clients import @legacy/core, which contains Zod validation schemas, Firebase service interfaces, RBAC permission checks, the AI service (with grounding/citation logic), the search index contract, and the offline mutation queue. Web and mobile are thin presentation layers over this shared contract — they never diverge on data semantics. Backend: Firebase — Firestore (primary database), Storage (photos, videos, voice, documents), Cloud Functions (validated mutations, notifications, AI, anniversary engine), FCM (push), Vertex AI (biography generation, Q&A). Security rules enforce membership, visibility, and ownership on every read and write; privileged mutations flow through callables with enforceAppCheck.
AI with guardrails: The AI service filters to approved memories only, sanitizes citations, and returns an honest "the archive doesn't hold that" when it can't ground an answer. Every generated paragraph links back to source memories.
Production hardening: App Check (reCAPTCHA v3 on web, App Attest + DeviceCheck on iOS, Play Integrity on Android), a privacy-reviewed telemetry facade (14-event vocabulary, zero PII), Crashlytics, an offline cache + mutation queue that survives process kills, and a justfile CI pipeline producing signed AABs and IPAs without EAS.
Scale: ~34,700 lines of TypeScript. 86 commits. 399+ core test assertions. 63 Firestore rules emulator tests.

Challenges we ran into

  1. The write-path schism. Both clients were mutating Firestore directly through databaseService while the security rules denied those writes. This was the largest architectural debt. Fixing it meant routing every privileged mutation through Cloud Functions, hardening the rules to require createdBy from the signed-in uid, and migrating both clients to call the callables — all while keeping Developer Mock Mode functional for local dev.
  2. Media persistence across platforms. Mobile voice recordings were file:// URIs. Web recorder previews were blob: URLs. Neither is durable. I had to reject both at the Zod schema level, build an upload pipeline to Firebase Storage, clean local files only after the callable confirms persistence, and make failed/cancelled uploads recoverable from the drafts store. A local file:// URI is a draft asset, never a shared memory URL.
  3. Offline reconciliation without data loss. A tribute reaction toggled offline → app killed → another family member toggles the same reaction on web → reconnect. What happens? I modeled mutations as intent (desired: add | remove) rather than operations. The queue replays in FIFO, checks the server's current state first, and reconciles: a conflicting toggle becomes a no-op, not an inversion. Permanent rejections are dropped and reported via telemetry; transient failures stay queued for the next sign-in.
  4. Making it feel like an heirloom, not an app. The design had to be anti-social-network. Warm ivory canvas (#F8F6F2), forest green accents, serif typography (Newsreader) for biographies, Inter for UI. No bright red badges, no bounce animations. Empty states say "Every life begins with a first story." Every contrast ratio was audited against WCAG 2.2 AA — decorative accent colors that didn't meet the bar got dedicated -Text variants.

  5. Cross-platform parity as a contract. Defining parity wasn't "both screens exist." It was five conditions: reachable native affordance, same typed contract, authenticated identity (never mock), server-enforced permissions, and █ complete loading/empty/error/offline/accessibility states. Meeting all five for every feature was harder than building the feature itself.

Accomplishments that we're proud of

  • Person-centric domain model that makes family trees, AI, search, and genealogy fall out naturally — no Memorial entity, just people and relationships

  • 399+ core test assertions and 63 Firestore rules emulator tests proving the permission model, schema validation, AI grounding, offline queue policy, and search ranking

  • AI that never invents — every answer cites approved memories, hidden/private memories are never leaked, and unanswerable questions return an honest decline

  • Offline-first mutation queue that captures intent, survives process kills, and reconciles against server state without data loss or inversion

  • Signed AAB + IPA production builds via justfile CI without EAS — RSA-2048 upload key valid to 2052, App Store export method, reproducible versioning

  • App Check enforcement across Firestore, Storage, Cloud Functions, and AI Logic — with platform-native attestation (App Attest, Play Integrity, reCAPTCHA v3)

  • A design system that feels like a family heirloom — warm, quiet, archival — with WCAG 2.2 AA contrast verified across every text/token combination

  • ~34,700 lines of shared, typed TypeScript across web, mobile, and Cloud Functions, with a single source of truth in @legacy/core

What I learnt

Building a platform that models people rather than pages taught me several hard lessons:

  1. Domain modeling is destiny. The single most important architectural decision was this: there is no Memorial entity. A person is a person — with a status field that's living, deceased, or historical. A memorial is just one view of a person. This made family trees, genealogy, AI biography generation, and collaboration almost free, because everything is already a graph of people, relationships, and memories.

  2. Cross-platform parity is a contract, not a goal. I defined parity as five conditions that all must hold: (1) a reachable native affordance, (2) the same typed @legacy/core contract, (3) authenticated identity (never mock), (4) server-enforced permissions, and (5) complete loading/empty/error/offline/accessibility states. Anything less is a demo, not parity.

  3. Security rules are the real API. Firestore rules aren't a backup safety net — they're the source of truth for what a user can do. I learned this the hard way when my client databaseService was performing direct writes that the
    rules denied. The fix wasn't to loosen the rules; it was to route privileged mutations through validated Cloud Functions and make the rules enforce membership, visibility, and ownership on every read and write.

  4. AI must ground, never invent. For a memorial platform, a hallucinated fact isn't a bug — it's a betrayal of someone's memory. I built the AI layer so every generated paragraph links back to source memories, every quote references a contributor, and unanswerable questions return an honest "the archive doesn't hold that" rather than a confident fabrication.

  5. Offline is a first-class state, not an error. I built a queue-based offline contract where failed mutations are captured as intent (desired: add | remove), survive a process kill via AsyncStorage, and reconcile against the server's current state on reconnect — so a conflicting toggle from another client becomes a no-op, not an inversion.

What's next for Still There

Future features AI narrated remembrance videos, AI photo recognition (faces, places, years), interactive family tree navigation, QR memorials, printed memorial books, digital time capsules, and historical archive mode for public discovery.

The mission stays the same: every person deserves a place where their story continues to grow.

Built With

  • accessibility
  • ai
  • appcheck
  • cloudfunctions
  • collaboration
  • crossplatform
  • expo.io
  • familytree
  • firebase
  • firestore
  • memorial
  • monorepo
  • offlinefirst
  • pushnotifications
  • react
  • storytelling
  • tailwind
  • typescript
  • vertexai
  • vite
  • zod
Share this project:

Updates

Submission history